diff --git a/.changeset/sto-bro-release.md b/.changeset/sto-bro-release.md new file mode 100644 index 00000000000..13304c4d25c --- /dev/null +++ b/.changeset/sto-bro-release.md @@ -0,0 +1,45 @@ +--- +"@aws-amplify/ui-react-storage": minor +"@aws-amplify/ui-react": minor +"@aws-amplify/ui-react-core": minor +"@aws-amplify/ui": minor +--- + +feat(storage-browser): add `StorageBrowser` and `createStorageBrowser` + +```tsx +import { Amplify } from 'aws-amplify'; + +import { StorageBrowser } from '@aws-amplify/ui-react-storage'; +import '@aws-amplify/ui-react-storage/styles.css'; + +import config from './aws-exports'; + +Amplify.configure(config); + +function App() { + return +} +``` + +```tsx +import { Amplify } from 'aws-amplify'; + +import { + createAmplifyAuthAdapter, + createStorageBrowser, +} from '@aws-amplify/ui-react-storage/browser'; +import '@aws-amplify/ui-react-storage/styles.css'; + +import config from './aws-exports'; + +Amplify.configure(config); + +const { StorageBrowser } = createStorageBrowser({ + config: createAmplifyAuthAdapter(), +}); + +function App() { + return +} +``` \ No newline at end of file diff --git a/docs/__tests__/__snapshots__/props-table.test.ts.snap b/docs/__tests__/__snapshots__/props-table.test.ts.snap index 49277ca027d..990d85af63d 100644 --- a/docs/__tests__/__snapshots__/props-table.test.ts.snap +++ b/docs/__tests__/__snapshots__/props-table.test.ts.snap @@ -770,7 +770,7 @@ exports[`Props Table 1`] = ` }, "errorMessage": { "name": "errorMessage", - "type": "string | undefined", + "type": "React.ReactNode", "description": " When defined and \`hasError\` is true, show error message", "category": "BaseFieldProps", "isOptional": true @@ -2692,7 +2692,7 @@ exports[`Props Table 1`] = ` }, "errorMessage": { "name": "errorMessage", - "type": "string | undefined", + "type": "React.ReactNode", "description": " When defined and \`hasError\` is true, show error message", "category": "BaseFieldProps", "isOptional": true @@ -7317,7 +7317,7 @@ exports[`Props Table 1`] = ` }, "errorMessage": { "name": "errorMessage", - "type": "string | undefined", + "type": "React.ReactNode", "description": " When defined and \`hasError\` is true, show error message", "category": "BaseFieldProps", "isOptional": true @@ -7679,7 +7679,7 @@ exports[`Props Table 1`] = ` }, "errorMessage": { "name": "errorMessage", - "type": "string | undefined", + "type": "React.ReactNode", "description": " When defined and \`hasError\` is true, show error message", "category": "BaseFieldProps", "isOptional": true @@ -8326,7 +8326,7 @@ exports[`Props Table 1`] = ` }, "errorMessage": { "name": "errorMessage", - "type": "string | undefined", + "type": "React.ReactNode", "description": " When defined and \`hasError\` is true, show error message", "category": "BaseFieldProps", "isOptional": true @@ -9457,7 +9457,7 @@ exports[`Props Table 1`] = ` }, "errorMessage": { "name": "errorMessage", - "type": "string | undefined", + "type": "React.ReactNode", "description": " When defined and \`hasError\` is true, show error message", "category": "BaseFieldProps", "isOptional": true @@ -9791,7 +9791,7 @@ exports[`Props Table 1`] = ` }, "errorMessage": { "name": "errorMessage", - "type": "string | undefined", + "type": "React.ReactNode", "description": " When defined and \`hasError\` is true, show error message", "category": "BaseFieldProps", "isOptional": true @@ -10272,7 +10272,7 @@ exports[`Props Table 1`] = ` }, "errorMessage": { "name": "errorMessage", - "type": "string | undefined", + "type": "React.ReactNode", "description": " When defined and \`hasError\` is true, show error message", "category": "BaseFieldProps", "isOptional": true @@ -10711,7 +10711,7 @@ exports[`Props Table 1`] = ` }, "errorMessage": { "name": "errorMessage", - "type": "string | undefined", + "type": "React.ReactNode", "description": " When defined and \`hasError\` is true, show error message", "category": "BaseFieldProps", "isOptional": true @@ -11220,7 +11220,7 @@ exports[`Props Table 1`] = ` }, "errorMessage": { "name": "errorMessage", - "type": "string | undefined", + "type": "React.ReactNode", "description": " When defined and \`hasError\` is true, show error message", "category": "BaseFieldProps", "isOptional": true @@ -13353,7 +13353,7 @@ exports[`Props Table 1`] = ` }, "errorMessage": { "name": "errorMessage", - "type": "string | undefined", + "type": "React.ReactNode", "description": " When defined and \`hasError\` is true, show error message", "category": "BaseFieldProps", "isOptional": true @@ -13722,7 +13722,7 @@ exports[`Props Table 1`] = ` }, "errorMessage": { "name": "errorMessage", - "type": "string | undefined", + "type": "React.ReactNode", "description": " When defined and \`hasError\` is true, show error message", "category": "BaseFieldProps", "isOptional": true diff --git a/docs/src/pages/[platform]/components/passwordfield/passwordFieldPropControls.tsx b/docs/src/pages/[platform]/components/passwordfield/passwordFieldPropControls.tsx index c5af8d9569c..c372ab619d6 100644 --- a/docs/src/pages/[platform]/components/passwordfield/passwordFieldPropControls.tsx +++ b/docs/src/pages/[platform]/components/passwordfield/passwordFieldPropControls.tsx @@ -131,7 +131,7 @@ export const PasswordFieldPropControls: PasswordFieldPropControlsInterface = ({ name="errorMessage" id="errorMessage" placeholder="set errorMessage" - value={errorMessage} + value={errorMessage as string} onChange={(event) => { setErrorMessage( event.target.value as PasswordFieldProps['errorMessage'] diff --git a/docs/src/pages/[platform]/components/selectfield/SelectFieldPropControls.tsx b/docs/src/pages/[platform]/components/selectfield/SelectFieldPropControls.tsx index 0ddbd6ad9e4..b86b4f6cd8b 100644 --- a/docs/src/pages/[platform]/components/selectfield/SelectFieldPropControls.tsx +++ b/docs/src/pages/[platform]/components/selectfield/SelectFieldPropControls.tsx @@ -116,7 +116,7 @@ export const SelectFieldPropControls: SelectFieldPropControlsInterface = ({ label="errorMessage" name="errorMessage" placeholder="Specify error message" - value={errorMessage} + value={errorMessage as string} onChange={(event) => setErrorMessage( event.target.value as SelectFieldProps['errorMessage'] diff --git a/docs/src/pages/[platform]/components/textareafield/TextAreaFieldPropControls.tsx b/docs/src/pages/[platform]/components/textareafield/TextAreaFieldPropControls.tsx index d1b3a849285..08bff3cab53 100644 --- a/docs/src/pages/[platform]/components/textareafield/TextAreaFieldPropControls.tsx +++ b/docs/src/pages/[platform]/components/textareafield/TextAreaFieldPropControls.tsx @@ -126,7 +126,7 @@ export const TextAreaFieldPropControls: TextAreaFieldControlsInterface = ({ { setErrorMessage(event.target.value); }} diff --git a/docs/storageMock.ts b/docs/storageMock.ts index 47d95ae48e6..297757babdf 100644 --- a/docs/storageMock.ts +++ b/docs/storageMock.ts @@ -5,8 +5,10 @@ import { getProperties, copy, getUrl, + isCancelError, + type UploadDataInput, + type UploadDataOutput, } from '@aws-amplify/storage'; -import type { UploadDataInput, UploadDataOutput } from '@aws-amplify/storage'; type UploadData = (props: UploadDataInput) => UploadDataOutput; @@ -66,4 +68,13 @@ const uploadData: UploadData = (props) => { }; }; -export { uploadData, downloadData, copy, remove, list, getUrl, getProperties }; +export { + isCancelError, + uploadData, + downloadData, + copy, + remove, + list, + getUrl, + getProperties, +}; diff --git a/examples/next/.env.example b/examples/next/.env.example index 5c6a3029059..35edcc7eb50 100644 --- a/examples/next/.env.example +++ b/examples/next/.env.example @@ -1,3 +1,10 @@ +NEXT_PUBLIC_BACKEND_API_URL= # Set this .env to the beta/gamma endpoints to test locally NEXT_PUBLIC_STREAMING_API_URL= NEXT_PUBLIC_BACKEND_API_REGION= +NEXT_PUBLIC_DEBUG= + +# StorageBrowser Managed Auth +NEXT_PUBLIC_MANAGED_AUTH_ACCOUNT_ID= +NEXT_PUBLIC_MANAGED_AUTH_ENDPOINT= +NEXT_PUBLIC_MANAGED_AUTH_REGION= \ No newline at end of file diff --git a/examples/next/.eslintrc b/examples/next/.eslintrc index e4f9606ab48..97a2bb84efb 100644 --- a/examples/next/.eslintrc +++ b/examples/next/.eslintrc @@ -1,3 +1,3 @@ { - "extends": ["next", "next/babel", "next/core-web-vitals"] + "extends": ["next", "next/core-web-vitals"] } diff --git a/examples/next/next-env.d.ts b/examples/next/next-env.d.ts index 4f11a03dc6c..a4a7b3f5cfa 100644 --- a/examples/next/next-env.d.ts +++ b/examples/next/next-env.d.ts @@ -2,4 +2,4 @@ /// // NOTE: This file should not be edited -// see https://nextjs.org/docs/basic-features/typescript for more information. +// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information. diff --git a/examples/next/pages/_document.page.tsx b/examples/next/pages/_document.page.tsx index 4122e24f77a..52f07545e8c 100644 --- a/examples/next/pages/_document.page.tsx +++ b/examples/next/pages/_document.page.tsx @@ -20,8 +20,6 @@ class MyDocument extends Document { React Example App -

React Example App

-
diff --git a/examples/next/pages/ui/components/storage/storage-browser/composable-playground/index.page.tsx b/examples/next/pages/ui/components/storage/storage-browser/composable-playground/index.page.tsx new file mode 100644 index 00000000000..66db5fb3850 --- /dev/null +++ b/examples/next/pages/ui/components/storage/storage-browser/composable-playground/index.page.tsx @@ -0,0 +1,179 @@ +import React from 'react'; + +import { + createManagedAuthAdapter, + CreateStorageBrowserInput, + createStorageBrowser, +} from '@aws-amplify/ui-react-storage/browser'; + +import { Auth } from '../managedAuthAdapter'; + +import { Button, Flex, Breadcrumbs } from '@aws-amplify/ui-react'; + +import '@aws-amplify/ui-react-storage/styles.css'; +import '@aws-amplify/ui-react-storage/storage-browser-styles.css'; + +const components: CreateStorageBrowserInput['components'] = { + Navigation: ({ items }) => ( + + {items.map(({ isCurrent, name, onNavigate }) => ( + + + {name} + + + ))} + + ), +}; + +export const auth = new Auth({ persistCredentials: true }); + +const config = createManagedAuthAdapter({ + credentialsProvider: auth.credentialsProvider, + region: process.env.NEXT_PUBLIC_MANAGED_AUTH_REGION, + accountId: process.env.NEXT_PUBLIC_MANAGED_AUTH_ACCOUNT_ID, + registerAuthListener: auth.registerAuthListener, +}); + +const { StorageBrowser, useView } = createStorageBrowser({ + components, + config, +}); + +const { CopyView, CreateFolderView, DeleteView, LocationActionView } = + StorageBrowser; + +const MyCopyView = () => { + const viewState = useView('Copy'); + const { isProcessing } = viewState; + + return ( + + {isProcessing ?

Copy in progress

: null} + + + + +
+ ); +}; + +const MyCreateFolderView = () => { + const viewState = useView('CreateFolder'); + const { isProcessing } = viewState; + return ( + + {isProcessing ?

Folder creation in progress

: null} + + +
+ ); +}; + +const MyDeleteView = () => { + const viewState = useView('Delete'); + const { isProcessing } = viewState; + return ( + + {isProcessing ?

Delete in progress

: null} + + +
+ ); +}; + +function MyLocationActionView({ type }: { type?: string }) { + let DialogContent = null; + if (!type) return DialogContent; + + switch (type) { + case 'copy': + DialogContent = MyCopyView; + break; + case 'createFolder': + DialogContent = MyCreateFolderView; + break; + case 'delete': + DialogContent = MyDeleteView; + break; + default: + DialogContent = LocationActionView; + } + + return ( + + + + ); +} + +function MyStorageBrowser() { + const [type, setActionType] = React.useState(undefined); + + return ( + + + + + + { + console.log(actionType); + setActionType(actionType); + }} + /> + + + + ); +} + +function Example() { + const [authenticated, setAuthenticated] = React.useState(false); + const [isLoading, setIsLoading] = React.useState(false); + const [errorMessage, setErrorMessage] = React.useState(); + + return !authenticated ? ( + + + {isLoading ? Authenticating... : null} + {errorMessage ? {errorMessage} : null} + + ) : ( + <> + + + + + + ); +} + +export default Example; diff --git a/examples/next/pages/ui/components/storage/storage-browser/default-auth/aws-exports.js b/examples/next/pages/ui/components/storage/storage-browser/default-auth/aws-exports.js new file mode 100644 index 00000000000..069327c41c7 --- /dev/null +++ b/examples/next/pages/ui/components/storage/storage-browser/default-auth/aws-exports.js @@ -0,0 +1,2 @@ +import awsExports from '@environments/storage/gen2/amplify_outputs.json'; +export default awsExports; diff --git a/examples/next/pages/ui/components/storage/storage-browser/default-auth/index.page.tsx b/examples/next/pages/ui/components/storage/storage-browser/default-auth/index.page.tsx new file mode 100644 index 00000000000..494fc67d94f --- /dev/null +++ b/examples/next/pages/ui/components/storage/storage-browser/default-auth/index.page.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { Amplify } from 'aws-amplify'; +import { signOut } from 'aws-amplify/auth'; + +import { + Button, + Flex, + IconsProvider, + View, + withAuthenticator, +} from '@aws-amplify/ui-react'; +import { StorageBrowser } from '@aws-amplify/ui-react-storage'; + +import '@aws-amplify/ui-react-storage/styles.css'; +import '@aws-amplify/ui-react-storage/storage-browser-styles.css'; + +import config from './aws-exports'; + +Amplify.configure(config); + +const IndeterminateIcon = () => ( + + + + + +); + +function Example() { + return ( + + + + }, + }} + > + + + + + ); +} + +export default withAuthenticator(Example); diff --git a/examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/StorageBrowser.ts b/examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/StorageBrowser.ts new file mode 100644 index 00000000000..de650f99e9c --- /dev/null +++ b/examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/StorageBrowser.ts @@ -0,0 +1,16 @@ +import { Amplify } from 'aws-amplify'; + +import { + createAmplifyAuthAdapter, + createStorageBrowser, +} from '@aws-amplify/ui-react-storage/browser'; +import '@aws-amplify/ui-react-storage/styles.css'; +import '@aws-amplify/ui-react-storage/storage-browser-styles.css'; + +import config from './aws-exports'; + +Amplify.configure(config); + +export const { StorageBrowser } = createStorageBrowser({ + config: createAmplifyAuthAdapter(), +}); diff --git a/examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/[locations]/[location-detail]/index.page.tsx b/examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/[locations]/[location-detail]/index.page.tsx new file mode 100644 index 00000000000..be01ddb40ca --- /dev/null +++ b/examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/[locations]/[location-detail]/index.page.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { useRouter } from 'next/router'; + +import { signOut } from 'aws-amplify/auth'; +import { Button, Flex } from '@aws-amplify/ui-react'; + +import { StorageBrowser } from '../../StorageBrowser'; + +import '@aws-amplify/ui-react-storage/storage-browser-styles.css'; +import '@aws-amplify/ui-react-storage/styles.css'; + +export default function Page() { + const [key, setKey] = React.useState(() => crypto.randomUUID()); + const { replace, query, pathname } = useRouter(); + + if (!query.bucket) return null; + + const { path, ...location } = query; + + // `useRouter` cannot force a query parameter to be array, however the `permissions` has to be array. + // When there's only 1 permission (e.g. ['list']), we need to force the `permissions` to be an array. + if (typeof location.permissions === 'string') { + location.permissions = [location.permissions]; + } + + return ( + + + `${location.key} - Routed Amplify Auth`, + }, + }} + > + { + replace({ query: { ...query, actionType } }); + }} + onNavigate={(location, path = '') => { + if (!location) { + return; + } + replace({ query: { ...location, path } }); + }} + onExit={() => { + replace(pathname.replace('/[locations]', '')); + }} + /> + {typeof query.actionType === 'string' ? ( + + { + setKey(() => crypto.randomUUID()); + replace({ + query: { ...query, actionType: undefined }, + }); + }} + /> + + ) : null} + + + ); +} diff --git a/examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/[locations]/index.page.tsx b/examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/[locations]/index.page.tsx new file mode 100644 index 00000000000..b50fd485c02 --- /dev/null +++ b/examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/[locations]/index.page.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { useRouter } from 'next/router'; + +import { signOut } from 'aws-amplify/auth'; +import { Button, Flex } from '@aws-amplify/ui-react'; + +import { StorageBrowser } from '../StorageBrowser'; + +import '@aws-amplify/ui-react-storage/storage-browser-styles.css'; +import '@aws-amplify/ui-react-storage/styles.css'; + +function Locations() { + const router = useRouter(); + + return ( + + + + { + router.push({ + pathname: `${router.pathname}/location-detail`, + query: { ...location }, + }); + }} + /> + + + ); +} + +export default Locations; diff --git a/examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/aws-exports.js b/examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/aws-exports.js new file mode 100644 index 00000000000..069327c41c7 --- /dev/null +++ b/examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/aws-exports.js @@ -0,0 +1,2 @@ +import awsExports from '@environments/storage/gen2/amplify_outputs.json'; +export default awsExports; diff --git a/examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/index.page.tsx b/examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/index.page.tsx new file mode 100644 index 00000000000..9cfc40de58b --- /dev/null +++ b/examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/index.page.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { useRouter } from 'next/router'; + +import useIsSignedIn from './useIsSignedIn'; + +import { Authenticator } from '@aws-amplify/ui-react'; + +import '@aws-amplify/ui-react-storage/styles.css'; +import '@aws-amplify/ui-react-storage/storage-browser-styles.css'; + +function Example() { + const router = useRouter(); + + useIsSignedIn({ + onSignIn: () => { + router.push(`${router.pathname}/locations`); + }, + }); + + return ; +} + +export default Example; diff --git a/examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/useIsSignedIn.ts b/examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/useIsSignedIn.ts new file mode 100644 index 00000000000..b3099704ccc --- /dev/null +++ b/examples/next/pages/ui/components/storage/storage-browser/default-auth/routed/useIsSignedIn.ts @@ -0,0 +1,71 @@ +import React from 'react'; + +import { fetchAuthSession } from 'aws-amplify/auth'; +import { Hub, HubCallback } from '@aws-amplify/core'; +import { isFunction } from '@aws-amplify/ui'; + +export interface UseIsSignedInParams { + onSignIn?: () => void; + onSignOut?: () => void; +} + +interface UseIsSignedIn { + isSignedIn: boolean; +} + +const INITIAL_STATE: UseIsSignedIn = { isSignedIn: false }; + +/** + * listens for `Auth` sign in and sign out events + * + * @param {UseIsSignedInParams} params `onSignIn` and `onSignOut` event callbacks + * @returns {UseIsSignedIn} Outputs `isSignedIn` + */ +export default function useIsSignedIn({ + onSignIn, + onSignOut, +}: UseIsSignedInParams): UseIsSignedIn { + const [output, setOutput] = React.useState( + () => INITIAL_STATE + ); + + React.useEffect(() => { + fetchAuthSession().then(() => { + if (isFunction(onSignIn)) onSignIn(); + }); + }, [onSignIn]); + + const handleEvent: HubCallback = React.useCallback( + ({ payload }) => { + switch (payload.event) { + case 'signIn': + case 'autoSignIn': { + if (isFunction(onSignIn)) { + onSignIn(); + } + setOutput({ isSignedIn: true }); + break; + } + case 'signOut': { + if (isFunction(onSignOut)) { + onSignOut(); + } + setOutput({ isSignedIn: false }); + break; + } + default: { + break; + } + } + }, + [onSignIn, onSignOut] + ); + + React.useEffect(() => { + const unsubscribe = Hub.listen('auth', handleEvent, 'useIsSignedIn'); + + return unsubscribe; + }, [handleEvent]); + + return output; +} diff --git a/examples/next/pages/ui/components/storage/storage-browser/managed-auth/index.page.tsx b/examples/next/pages/ui/components/storage/storage-browser/managed-auth/index.page.tsx new file mode 100644 index 00000000000..18f2c1f20e9 --- /dev/null +++ b/examples/next/pages/ui/components/storage/storage-browser/managed-auth/index.page.tsx @@ -0,0 +1,40 @@ +import React from 'react'; + +import { createStorageBrowser } from '@aws-amplify/ui-react-storage/browser'; + +import { managedAuthAdapter } from '../managedAuthAdapter'; +import { SignIn, SignOutButton } from './routed/components'; + +import { Flex, View } from '@aws-amplify/ui-react'; + +import '@aws-amplify/ui-react-storage/styles.css'; +import '@aws-amplify/ui-react-storage/storage-browser-styles.css'; + +const { StorageBrowser } = createStorageBrowser({ + config: managedAuthAdapter, +}); + +function Example() { + const [showSignIn, setShowSignIn] = React.useState(false); + + return !showSignIn ? ( + setShowSignIn(true)} /> + ) : ( + + setShowSignIn(false)} /> + + + + + ); +} + +export default Example; diff --git a/examples/next/pages/ui/components/storage/storage-browser/managed-auth/routed/StorageBrowser.ts b/examples/next/pages/ui/components/storage/storage-browser/managed-auth/routed/StorageBrowser.ts new file mode 100644 index 00000000000..2ac1ef914b9 --- /dev/null +++ b/examples/next/pages/ui/components/storage/storage-browser/managed-auth/routed/StorageBrowser.ts @@ -0,0 +1,16 @@ +import { Auth } from '../../managedAuthAdapter'; +import { + createManagedAuthAdapter, + createStorageBrowser, +} from '@aws-amplify/ui-react-storage/browser'; + +export const routedAuth = new Auth({ persistCredentials: true }); + +const config = createManagedAuthAdapter({ + credentialsProvider: routedAuth.credentialsProvider, + region: process.env.NEXT_PUBLIC_MANAGED_AUTH_REGION, + accountId: process.env.NEXT_PUBLIC_MANAGED_AUTH_ACCOUNT_ID, + registerAuthListener: routedAuth.registerAuthListener, +}); + +export const { StorageBrowser, useView } = createStorageBrowser({ config }); diff --git a/examples/next/pages/ui/components/storage/storage-browser/managed-auth/routed/[locations]/[location-detail]/index.page.tsx b/examples/next/pages/ui/components/storage/storage-browser/managed-auth/routed/[locations]/[location-detail]/index.page.tsx new file mode 100644 index 00000000000..f97c16a4e6c --- /dev/null +++ b/examples/next/pages/ui/components/storage/storage-browser/managed-auth/routed/[locations]/[location-detail]/index.page.tsx @@ -0,0 +1,64 @@ +import { useRouter } from 'next/router'; + +import { Flex } from '@aws-amplify/ui-react'; + +import { SignOutButton } from '../../components'; +import { StorageBrowser } from '../../StorageBrowser'; + +import '@aws-amplify/ui-react-storage/styles.css'; +import '@aws-amplify/ui-react-storage/storage-browser-styles.css'; + +export default function Page() { + const { back, query, pathname, replace } = useRouter(); + + if (!query.bucket) return null; + + const { path, ...location } = query; + + // `useRouter` cannot force a query parameter to be array, however the `permissions` has to be array. + // When there's only 1 permission (e.g. ['list']), we need to force the `permissions` to be an array. + if (typeof location.permissions === 'string') { + location.permissions = [location.permissions]; + } + + return ( + + { + replace(pathname.replace('[locations]/[location-detail]', '')); + }} + /> + + { + replace({ query: { ...query, actionType } }); + }} + onNavigate={(location, path = '') => { + if (!location) { + return; + } + replace({ query: { ...location, path } }); + }} + onExit={() => { + back(); + }} + /> + {typeof query.actionType === 'string' ? ( + + { + replace({ query: { ...query, actionType: undefined } }); + }} + /> + + ) : null} + + + ); +} diff --git a/examples/next/pages/ui/components/storage/storage-browser/managed-auth/routed/[locations]/index.page.tsx b/examples/next/pages/ui/components/storage/storage-browser/managed-auth/routed/[locations]/index.page.tsx new file mode 100644 index 00000000000..dd84cfba9ba --- /dev/null +++ b/examples/next/pages/ui/components/storage/storage-browser/managed-auth/routed/[locations]/index.page.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { useRouter } from 'next/router'; + +import { Flex } from '@aws-amplify/ui-react'; + +import { SignOutButton } from '../components'; +import { StorageBrowser } from '../StorageBrowser'; + +import '@aws-amplify/ui-react-storage/styles.css'; +import '@aws-amplify/ui-react-storage/storage-browser-styles.css'; + +function Locations() { + const router = useRouter(); + + return ( + + { + router.replace(router.pathname.replace('[locations]', '')); + }} + /> + + + { + router.push({ + pathname: `${router.pathname}/location-detail`, + query: { ...location }, + }); + }} + /> + + + ); +} + +export default Locations; diff --git a/examples/next/pages/ui/components/storage/storage-browser/managed-auth/routed/components.tsx b/examples/next/pages/ui/components/storage/storage-browser/managed-auth/routed/components.tsx new file mode 100644 index 00000000000..62672c7ce3c --- /dev/null +++ b/examples/next/pages/ui/components/storage/storage-browser/managed-auth/routed/components.tsx @@ -0,0 +1,54 @@ +import React from 'react'; + +import { Button, Flex } from '@aws-amplify/ui-react'; + +import { routedAuth } from './StorageBrowser'; + +export function SignIn({ onSignIn }: { onSignIn?: () => void }) { + const [isLoading, setIsLoading] = React.useState(false); + const [errorMessage, setErrorMessage] = React.useState(); + + return ( + + + + {isLoading ? Authenticating... : null} + {errorMessage ? {errorMessage} : null} + + ); +} + +export function SignOutButton({ onSignOut }: { onSignOut?: () => void }) { + return ( + + ); +} diff --git a/examples/next/pages/ui/components/storage/storage-browser/managed-auth/routed/index.page.tsx b/examples/next/pages/ui/components/storage/storage-browser/managed-auth/routed/index.page.tsx new file mode 100644 index 00000000000..74f25a57ee0 --- /dev/null +++ b/examples/next/pages/ui/components/storage/storage-browser/managed-auth/routed/index.page.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { useRouter } from 'next/router'; + +import { SignIn } from './components'; + +import '@aws-amplify/ui-react-storage/styles.css'; + +function Example() { + const router = useRouter(); + + return ( + { + router.push(`${router.pathname}/locations`); + }} + /> + ); +} + +export default Example; diff --git a/examples/next/pages/ui/components/storage/storage-browser/managedAuthAdapter.ts b/examples/next/pages/ui/components/storage/storage-browser/managedAuthAdapter.ts new file mode 100644 index 00000000000..f6f8051c1df --- /dev/null +++ b/examples/next/pages/ui/components/storage/storage-browser/managedAuthAdapter.ts @@ -0,0 +1,117 @@ +import { + createManagedAuthAdapter, + CreateManagedAuthAdapterInput, +} from '@aws-amplify/ui-react-storage/browser'; + +type CredentialsProvider = CreateManagedAuthAdapterInput['credentialsProvider']; +type Credentials = Awaited>; + +export class Auth { + #persistCredentials: boolean; + #credentials: Credentials | undefined; + #onAuthStatusChange: () => void | undefined; + + constructor(options?: { persistCredentials?: boolean }) { + const { persistCredentials = false } = options ?? {}; + this.#persistCredentials = persistCredentials; + } + + #clearCredentials() { + this.#onAuthStatusChange?.(); + this.#onAuthStatusChange = undefined; + localStorage.removeItem('creds'); + this.#credentials = undefined; + } + + #getCredentials(): Credentials { + if (this.#persistCredentials) { + return JSON.parse(localStorage.getItem('creds')); + } + + return this.#credentials; + } + + #setCredentials(credentials: Credentials) { + if (this.#persistCredentials) { + localStorage.setItem('creds', JSON.stringify(credentials)); + } + this.#credentials = credentials; + } + + async #fetchCredentials(options?: { + forceRefresh?: boolean; + }): Promise { + const { forceRefresh = false } = options ?? {}; + + if (forceRefresh) { + this.#clearCredentials(); + } + const credentials = this.#getCredentials(); + if (!forceRefresh && credentials) { + return credentials; + } + + try { + const response = await fetch( + process.env.NEXT_PUBLIC_MANAGED_AUTH_ENDPOINT + ); + + const data = await response.json(); + + const { + Credentials: { + AccessKeyId: accessKeyId, + SecretAccessKey: secretAccessKey, + SessionToken: sessionToken, + Expiration: expiration, + }, + } = await JSON.parse(data.body); + + this.#setCredentials({ + credentials: { accessKeyId, secretAccessKey, sessionToken, expiration }, + }); + + return this.#getCredentials(); + } catch (e) { + if (e instanceof Error) { + throw new Error(e.message); + } + } + } + + get credentialsProvider(): CredentialsProvider { + return (options) => this.#fetchCredentials(options); + } + + registerAuthListener = (onAuthStatusChange: () => void) => { + this.#onAuthStatusChange = onAuthStatusChange; + }; + + async signIn(input?: { + forceRefresh?: boolean; + onSignIn?: () => void; + onError?: (e: Error) => void; + }): Promise { + const { forceRefresh, onError, onSignIn } = input ?? {}; + try { + await this.#fetchCredentials({ forceRefresh }); + onSignIn?.(); + } catch (e) { + onError?.(e); + } + } + + signOut(input?: { onSignOut?: () => void }) { + this.#clearCredentials(); + input?.onSignOut?.(); + } +} + +export const auth = new Auth(); + +export const managedAuthAdapter = createManagedAuthAdapter({ + credentialsProvider: auth.credentialsProvider, + region: process.env.NEXT_PUBLIC_MANAGED_AUTH_REGION, + accountId: process.env.NEXT_PUBLIC_MANAGED_AUTH_ACCOUNT_ID, + registerAuthListener: auth.registerAuthListener, +}); diff --git a/examples/next/tsconfig.json b/examples/next/tsconfig.json index 01eb8a036ea..2407dae743f 100644 --- a/examples/next/tsconfig.json +++ b/examples/next/tsconfig.json @@ -1,11 +1,7 @@ { "compilerOptions": { - "target": "es5", - "lib": [ - "dom", - "dom.iterable", - "esnext" - ], + "target": "ES2020", + "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "strict": false, @@ -19,18 +15,10 @@ "jsx": "preserve", "baseUrl": "./", "paths": { - "@environments/*": [ - "../../environments/*" - ] + "@environments/*": ["../../environments/*"] }, "incremental": true }, - "include": [ - "next-env.d.ts", - "**/*.ts", - "**/*.tsx" - ], - "exclude": [ - "node_modules" - ] + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], + "exclude": ["node_modules"] } diff --git a/package.json b/package.json index 3c1a4c9db4e..1d907c2480a 100644 --- a/package.json +++ b/package.json @@ -125,7 +125,7 @@ "@types/jest": "^29.5.5", "@types/react-test-renderer": "^18.0.2", "@vitejs/plugin-vue": "^2.3.4", - "aws-amplify": "^6.6.5", + "aws-amplify": "unstable", "esbuild-register": "^3.5.0", "eslint": "^8.44.0", "fs-extra": "^11.1.1", diff --git a/packages/angular/projects/ui-angular/package.json b/packages/angular/projects/ui-angular/package.json index f41cda06bfd..72bd4e60777 100644 --- a/packages/angular/projects/ui-angular/package.json +++ b/packages/angular/projects/ui-angular/package.json @@ -16,7 +16,7 @@ "peerDependencies": { "@angular/core": ">= 14.0.0", "@angular/common": ">= 14.0.0", - "aws-amplify": "^6.6.5", + "aws-amplify": "unstable", "rxjs": "^6.5.3 || ^7.4.0" }, "dependencies": { diff --git a/packages/e2e/.gitignore b/packages/e2e/.gitignore index f024081e4ca..895c36bac02 100644 --- a/packages/e2e/.gitignore +++ b/packages/e2e/.gitignore @@ -2,6 +2,7 @@ cypress/fixtures/example.json cypress/screenshots cypress/videos +cypress/downloads detox/screenshots diff --git a/packages/e2e/cypress/integration/common/shared.ts b/packages/e2e/cypress/integration/common/shared.ts index 810dad81a9f..806b919b2dc 100644 --- a/packages/e2e/cypress/integration/common/shared.ts +++ b/packages/e2e/cypress/integration/common/shared.ts @@ -8,6 +8,17 @@ import { get, escapeRegExp } from 'lodash'; let language = 'en-US'; let window = null; let stub = null; +const randomFileName = `fileName${Math.random() * 10000}`; + +const clickButtonWithText = (name: string) => { + cy.findByRole('button', { + name: new RegExp(`${escapeRegExp(name)}`, 'i'), + }).click(); +}; + +const typeInInputHandler = (field: string, value: string) => { + cy.findInputField(field).type(value); +}; const getRoute = (routeMatcher: { headers: { [key: string]: string } }) => { return `${routeMatcher.headers?.['X-Amz-Target'] || 'route'}`; @@ -98,6 +109,22 @@ Given( } ); +Given( + 'I intercept a {string} request to {string}', + (method: string, endpoint: string) => { + cy.intercept(method, endpoint).as(`${method}_REQUEST`); + } +); + +Then( + 'I confirm the {string} request has a status of {string}', + (method: string, statusCode: string) => { + cy.wait(`@${method}_REQUEST`) + .its('response.statusCode') + .should('eq', +statusCode); + } +); + Given('I spy request {string}', (json: string) => { let routeMatcher; @@ -223,11 +250,16 @@ When('I type a new {string}', (field: string) => { cy.findInputField(field).typeAliasWithStatus(field, `${Date.now()}`); }); -const typeInInputHandler = (field: string, value: string) => { - cy.findInputField(field).type(value); -}; When('I type a new {string} with value {string}', typeInInputHandler); +When('I type a new {string} with random value', (field: string) => { + typeInInputHandler(field, randomFileName); +}); + +When('I lose focus on {string} input', (field: string) => { + cy.findInputField(field).blur(); +}); + When('I click the {string} tab', (label: string) => { cy.findByRole('tab', { name: new RegExp(`^${escapeRegExp(label)}$`, 'i'), @@ -244,6 +276,44 @@ When('I click the {string} button', (name: string) => { }).click(); }); +Then('I press the {string} key', (key: string) => { + cy.get('body').type(key); +}); + +When('I click the button containing {string}', clickButtonWithText); + +When('I click the button containing random name', () => { + clickButtonWithText(randomFileName); +}); + +When('I click the first button containing {string}', (name: string) => { + cy.findAllByRole('button', { + name: new RegExp(`${escapeRegExp(name)}`, 'i'), + }) + .first() + .click(); +}); + +Then('I see the button containing {string}', (name: string) => { + cy.findByRole('button', { + name: new RegExp(`${escapeRegExp(name)}`, 'i'), + }).should('exist'); +}); + +Then('I do not see the button containing {string}', (name: string) => { + cy.findByRole('button', { + name: new RegExp(`${escapeRegExp(name)}`, 'i'), + }).should('not.exist'); +}); + +Then('I see the first button containing {string}', (name: string) => { + cy.findAllByRole('button', { + name: new RegExp(`${escapeRegExp(name)}`, 'i'), + }) + .first() + .should('exist'); +}); + Then('I see the {string} button', (name: string) => { cy.findByRole('button', { name: new RegExp(`^${escapeRegExp(name)}$`, 'i'), @@ -256,6 +326,18 @@ Then('I do not see the {string} button', (name: string) => { }).should('not.exist'); }); +When('I click the {string} menuitem', (label: string) => { + cy.findByRole('menuitem', { + name: new RegExp(`^${escapeRegExp(label)}$`, 'i'), + }).click(); +}); + +Then('I see the {string} menuitem', (label: string) => { + cy.findByRole('menuitem', { + name: new RegExp(`^${escapeRegExp(label)}$`, 'i'), + }).should('exist'); +}); + When('I click the {string} checkbox', (label: string) => { cy.findByLabelText(new RegExp(`^${escapeRegExp(label)}`, 'i')).click({ // We have to force this click because the checkbox button isn't visible by default @@ -286,6 +368,12 @@ Then('I see {string}', (message: string) => { .should('exist'); }); +Then('I do not see {string}', (message: string) => { + cy.findByRole('document') + .contains(new RegExp(escapeRegExp(message), 'i')) + .should('not.exist'); +}); + Then('I see {string} element', (id: string) => { cy.findByTestId(id).should('exist'); }); @@ -476,6 +564,13 @@ When('I type my new password', () => { cy.findInputField('New Password').type(Cypress.env('VALID_PASSWORD')); }); +When( + 'I see input with placeholder {string} and type {string}', + (name: string, value: string) => { + cy.findByPlaceholderText(name).type(value); + } +); + Then('I click the submit button', () => { /** * Submit button text differs on React/Vue vs Angular. Testing for both for diff --git a/packages/e2e/cypress/integration/common/storagebrowser.ts b/packages/e2e/cypress/integration/common/storagebrowser.ts new file mode 100644 index 00000000000..4254656b82c --- /dev/null +++ b/packages/e2e/cypress/integration/common/storagebrowser.ts @@ -0,0 +1,67 @@ +import { When, Then } from '@badeball/cypress-cucumber-preprocessor'; + +When( + 'I drag and drop a file into the storage browser with file name {string}', + (fileName: string) => { + cy.get('.amplify-storage-browser__drop-zone').trigger('drop', { + dataTransfer: { + files: [ + new File(['file contents'], fileName, { + type: 'text/plain', + lastModified: Date.now(), + }), + ], + }, + /** + * Since the input is hidden, this will need to be forced through Cypress + */ + force: true, + }); + } +); + +When( + 'I drag and drop a folder into the storage browser with name {string}', + (folderName: string) => { + cy.get('.amplify-storage-browser__drop-zone').trigger('drop', { + dataTransfer: { + files: [new File([], folderName, { lastModified: Date.now() })], + }, + /** + * Since the input is hidden, this will need to be forced through Cypress + */ + force: true, + }); + } +); + +Then('I see download for file {string}', (name: string) => { + cy.contains('table tbody td:nth-child(2)', new RegExp('^' + name + '$')) + .siblings() + .last() + .children('button'); +}); + +Then('I see no download for folder {string}', (name: string) => { + cy.contains('table tbody td:nth-child(2)', new RegExp('^' + name + '$')) + .siblings() + .last() + .children('div'); +}); + +Then('I click and see download succeed for {string}', (name: string) => { + cy.intercept('HEAD', 'https://*.s3.*.amazonaws.com/**').as( + 'downloadValidation' + ); + + cy.contains('table tbody td:nth-child(2)', new RegExp('^' + name + '$')) + .siblings() + .last() + .within(() => { + cy.get('button').click({ force: true }); + + cy.wait('@downloadValidation').then((interception) => { + assert.equal(interception.response.statusCode, 200); + }); + }); +}); diff --git a/packages/e2e/features/ui/components/storage/storage-browser/create-folder.feature b/packages/e2e/features/ui/components/storage/storage-browser/create-folder.feature new file mode 100644 index 00000000000..be601233131 --- /dev/null +++ b/packages/e2e/features/ui/components/storage/storage-browser/create-folder.feature @@ -0,0 +1,63 @@ +Feature: Create folder with Storage Browser + + Background: + Given I'm running the example "ui/components/storage/storage-browser/default-auth" + + @react + Scenario: Create folder successfully creates a new empty folder + When I type my "email" with status "CONFIRMED" + Then I type my password + Then I click the "Sign in" button + When I click the first button containing "public" + When I click the first button containing "DoNotDeleteThisFolder_CanDeleteAllChildren" + Then I see the "Menu Toggle" button + When I click the "Menu Toggle" button + Then I see the "Create Folder" menuitem + When I click the "Create Folder" menuitem + Then I see "Folder name" + Then I see the "Create Folder" button + Then the "Create Folder" button is disabled + When I type a new "Folder name" with random value + Then I see the "Create Folder" button + When I click the "Create Folder" button + Then I see "Folder created" + # verify folder creation + When I click the "Exit" button + Then I click the button containing random name + Then I see "No files" + # TODO: delete created folder + + @react + Scenario: Create folder fails on overwrite of existing folder name + When I type my "email" with status "CONFIRMED" + Then I type my password + Then I click the "Sign in" button + When I click the first button containing "public" + Then I see the "Menu Toggle" button + When I click the "Menu Toggle" button + Then I see the "Create Folder" menuitem + When I click the "Create Folder" menuitem + Then I see "Folder name" + Then I see the "Create Folder" button + Then the "Create Folder" button is disabled + When I type a new "Folder name" with value "Blackberry" + Then I see the "Create Folder" button + When I click the "Create Folder" button + Then I see "A folder already exists with the provided name" + + @react + Scenario: Create folder input shows error message when folder name contains "/" + When I type my "email" with status "CONFIRMED" + Then I type my password + Then I click the "Sign in" button + When I click the first button containing "public" + Then I see the "Menu Toggle" button + When I click the "Menu Toggle" button + Then I see the "Create Folder" menuitem + When I click the "Create Folder" menuitem + Then I see "Folder name" + Then I see the "Create Folder" button + Then the "Create Folder" button is disabled + When I type a new "Folder name" with value "Blackberry/" + When I lose focus on "Folder name" input + Then I see 'Folder name cannot contain \"/\", nor end or start with \".\"' diff --git a/packages/e2e/features/ui/components/storage/storage-browser/download.feature b/packages/e2e/features/ui/components/storage/storage-browser/download.feature new file mode 100644 index 00000000000..74d7c34673e --- /dev/null +++ b/packages/e2e/features/ui/components/storage/storage-browser/download.feature @@ -0,0 +1,21 @@ +Feature: Download on Storage Browser + + Background: + Given I'm running the example "ui/components/storage/storage-browser/default-auth" + + @react + Scenario: Download is available for files + When I type my "email" with status "CONFIRMED" + Then I type my password + Then I click the "Sign in" button + When I click the first button containing "public" + Then I see download for file "001_dont_delete_file.txt" + Then I click and see download succeed for "001_dont_delete_file.txt" + + @react + Scenario: Download is not available for folder + When I type my "email" with status "CONFIRMED" + Then I type my password + Then I click the "Sign in" button + When I click the first button containing "public" + Then I see no download for folder "DO_NOT_DELETE/" diff --git a/packages/e2e/features/ui/components/storage/storage-browser/drag-and-drop.feature b/packages/e2e/features/ui/components/storage/storage-browser/drag-and-drop.feature new file mode 100644 index 00000000000..616dd8c9a20 --- /dev/null +++ b/packages/e2e/features/ui/components/storage/storage-browser/drag-and-drop.feature @@ -0,0 +1,41 @@ +Feature: Drag and drop files within Storage Browser + + Background: + Given I'm running the example "ui/components/storage/storage-browser/default-auth" + + @react + Scenario: Drag and drop file into Location Detail view + When I type my "email" with status "CONFIRMED" + Then I type my password + Then I click the "Sign in" button + When I click the first button containing "public" + When I drag and drop a file into the storage browser with file name "test.txt" + Then I see "Upload" + Then I see "test.txt" + + @react + Scenario: Drag and drop folder into Location Detail view + When I type my "email" with status "CONFIRMED" + Then I type my password + Then I click the "Sign in" button + When I click the first button containing "public" + When I drag and drop a folder into the storage browser with name "test" + Then I see "Upload" + Then I see "test" + + + @react + Scenario: Drag and drop file into Upload Action view + When I type my "email" with status "CONFIRMED" + Then I type my password + Then I click the "Sign in" button + When I click the first button containing "public" + Then I see the "Menu Toggle" button + When I click the "Menu Toggle" button + Then I see the "Upload" menuitem + Then I click the "Upload" menuitem + # Close the file select menu + Then I press the "{esc}" key + When I drag and drop a file into the storage browser with file name "test.txt" + Then I see "test.txt" + \ No newline at end of file diff --git a/packages/e2e/features/ui/components/storage/storage-browser/filter-locations.feature b/packages/e2e/features/ui/components/storage/storage-browser/filter-locations.feature new file mode 100644 index 00000000000..60a7a44fc40 --- /dev/null +++ b/packages/e2e/features/ui/components/storage/storage-browser/filter-locations.feature @@ -0,0 +1,18 @@ +Feature: StorageBrowser Filter Locations + + Background: + Given I'm running the example "ui/components/storage/storage-browser/default-auth" + + @react + Scenario: Filter locations + When I type my "email" with status "CONFIRMED" + Then I type my password + Then I click the "Sign in" button + Then I see the first button containing "private" + When I see input with placeholder "Filter folders and files" and type "pu" + Then I click the "Search" button + Then I see the first button containing "public" + Then I do not see the button containing "private" + When I click the button containing "Clear search" + Then I see the first button containing "private" + diff --git a/packages/e2e/features/ui/components/storage/storage-browser/navigate-locations.feature b/packages/e2e/features/ui/components/storage/storage-browser/navigate-locations.feature new file mode 100644 index 00000000000..03f0566cfe7 --- /dev/null +++ b/packages/e2e/features/ui/components/storage/storage-browser/navigate-locations.feature @@ -0,0 +1,42 @@ +Feature: Storage Browser navigate breadcrumbs + + Background: + Given I'm running the example "ui/components/storage/storage-browser/default-auth" + + @react + Scenario: Navigate back to Home + When I type my "email" with status "CONFIRMED" + Then I type my password + Then I click the "Sign in" button + When I click the first button containing "public" + Then I see the "Home" button + When I click the "Home" button + Then I see "Home - Amplify Auth" + Then I see the first button containing "public" + + @react + Scenario: Navigate back up to prefix + When I type my "email" with status "CONFIRMED" + Then I type my password + Then I click the "Sign in" button + When I click the first button containing "public" + Then I see the "DO_NOT_DELETE/" button + When I click the "DO_NOT_DELETE/" button + Then I see "DONT_DELETE_SUB/" + When I click the "DONT_DELETE_SUB/" button + Then I see "DO_NOT_DELETE" + Then I see "DONT_DELETE_SUB" + + @react + Scenario: Navigate to parent folder from nested child folder + When I type my "email" with status "CONFIRMED" + Then I type my password + Then I click the "Sign in" button + When I click the first button containing "public" + Then I see the "DO_NOT_DELETE/" button + When I click the "DO_NOT_DELETE/" button + Then I see the "DONT_DELETE_SUB/" button + When I click the "DONT_DELETE_SUB/" button + Then I see "DO_NOT_DELETE" + When I click the "DO_NOT_DELETE" button + Then I see "DONT_DELETE_SUB/" diff --git a/packages/e2e/features/ui/components/storage/storage-browser/search-locations.feature b/packages/e2e/features/ui/components/storage/storage-browser/search-locations.feature new file mode 100644 index 00000000000..00b1de0fa87 --- /dev/null +++ b/packages/e2e/features/ui/components/storage/storage-browser/search-locations.feature @@ -0,0 +1,47 @@ +Feature: Search with Storage Browser + + Background: + Given I'm running the example "ui/components/storage/storage-browser/default-auth" + + @react + Scenario: Basic search returns correct results + When I type my "email" with status "CONFIRMED" + Then I type my password + Then I click the "Sign in" button + When I click the first button containing "public" + Then I see the button containing "test+name" + When I see input with placeholder "Search current folder" and type "DO_NOT" + Then I click the "Search" button + Then I see the button containing "DO_NOT_DELETE" + Then I do not see the button containing "test+name" + # Verify clear returns to initial state + When I click the button containing "Clear search" + Then I see the button containing "test+name" + + @react + Scenario: Search within sub-directories + When I type my "email" with status "CONFIRMED" + Then I type my password + Then I click the "Sign in" button + When I click the first button containing "public" + When I see input with placeholder "Search current folder" and type "DELETE" + Then I click the "Search" button + Then I do not see the button containing "DO_NOT_DELETE/DONT_DELETE_SUB" + When I click the button containing "Clear search" + When I click the "Include Subfolders" checkbox + When I see input with placeholder "Search current folder" and type "DELETE" + Then I click the "Search" button + Then I see "DO_NOT_DELETE/DONT_DELETE_SUB" + + @react + Scenario: Search with no matching results + When I type my "email" with status "CONFIRMED" + Then I type my password + Then I click the "Sign in" button + When I click the first button containing "public" + When I see input with placeholder "Search current folder" and type "XXXXXXXX" + Then I click the "Search" button + Then I see "No files" + # Verify clear removes message + Then I click the button containing "Clear search" + Then I do not see "No files" diff --git a/packages/react-ai/package.json b/packages/react-ai/package.json index 5166e02d06a..42ef11149eb 100644 --- a/packages/react-ai/package.json +++ b/packages/react-ai/package.json @@ -42,8 +42,8 @@ "typecheck": "tsc --noEmit" }, "peerDependencies": { - "@aws-amplify/api-graphql": "^4.3.0", - "aws-amplify": "^6.6.5", + "@aws-amplify/api-graphql": "unstable", + "aws-amplify": "unstable", "react": "^16.14.0 || ^17.0 || ^18.0", "react-dom": "^16.14.0 || ^17.0 || ^18.0" }, diff --git a/packages/react-ai/src/components/AIConversation/context/elements/IconElement.tsx b/packages/react-ai/src/components/AIConversation/context/elements/IconElement.tsx index 6550ab9cfc8..d3112b1145c 100644 --- a/packages/react-ai/src/components/AIConversation/context/elements/IconElement.tsx +++ b/packages/react-ai/src/components/AIConversation/context/elements/IconElement.tsx @@ -1,5 +1,5 @@ import { - defineBaseElement, + defineBaseElementWithRef, withBaseElementProps, } from '@aws-amplify/ui-react-core/elements'; import React from 'react'; @@ -36,7 +36,11 @@ const DEFAULT_ICON_ATTRIBUTES = { xmlns: 'http://www.w3.org/2000/svg', }; -export const BaseIconElement = defineBaseElement<'svg', never, IconVariant>({ +export const BaseIconElement = defineBaseElementWithRef< + 'svg', + never, + IconVariant +>({ type: 'svg', displayName: 'Icon', }); diff --git a/packages/react-ai/src/components/AIConversation/context/elements/definitions.ts b/packages/react-ai/src/components/AIConversation/context/elements/definitions.ts index ec67a5bfbcb..4ab3cb0a9e7 100644 --- a/packages/react-ai/src/components/AIConversation/context/elements/definitions.ts +++ b/packages/react-ai/src/components/AIConversation/context/elements/definitions.ts @@ -1,4 +1,4 @@ -import { defineBaseElement } from '@aws-amplify/ui-react-core/elements'; +import { defineBaseElementWithRef } from '@aws-amplify/ui-react-core/elements'; import { IconElement } from './IconElement'; export interface AIConversationElements { @@ -16,27 +16,27 @@ export interface AIConversationElements { View: typeof ViewElement; } -export const LabelElement = defineBaseElement<'label', 'htmlFor'>({ +export const LabelElement = defineBaseElementWithRef<'label', 'htmlFor'>({ type: 'label', displayName: 'Label', }); -export const TextElement = defineBaseElement({ +export const TextElement = defineBaseElementWithRef({ type: 'p', displayName: 'Text', }); -export const UnorderedListElement = defineBaseElement({ +export const UnorderedListElement = defineBaseElementWithRef({ type: 'ul', displayName: 'UnorderedList', }); -export const ListItemElement = defineBaseElement({ +export const ListItemElement = defineBaseElementWithRef({ type: 'li', displayName: 'ListItem', }); -export const HeadingElement = defineBaseElement({ +export const HeadingElement = defineBaseElementWithRef({ type: 'h2', displayName: 'Title', }); @@ -44,12 +44,12 @@ export const HeadingElement = defineBaseElement({ export type IconElementProps = React.ComponentProps; type ImageElementProps = 'src' | 'alt'; -export const ImageElement = defineBaseElement<'img', ImageElementProps>({ +export const ImageElement = defineBaseElementWithRef<'img', ImageElementProps>({ type: 'img', displayName: 'Image', }); -export const InputElement = defineBaseElement<'input', 'type'>({ +export const InputElement = defineBaseElementWithRef<'input', 'type'>({ type: 'input', displayName: 'Input', }); @@ -57,7 +57,7 @@ export const InputElement = defineBaseElement<'input', 'type'>({ type ButtonElementProps = 'disabled' | 'onClick' | 'type' | 'tabIndex'; type ButtonElementVariant = 'attach' | 'remove' | 'send-message'; -export const ButtonElement = defineBaseElement< +export const ButtonElement = defineBaseElementWithRef< 'button', ButtonElementProps, ButtonElementVariant @@ -65,12 +65,12 @@ export const ButtonElement = defineBaseElement< type ViewElementProps = 'onFocus' | 'tabIndex' | 'onKeyDown'; -export const ViewElement = defineBaseElement<'div', ViewElementProps>({ +export const ViewElement = defineBaseElementWithRef<'div', ViewElementProps>({ type: 'div', displayName: 'View', }); -export const SpanElement = defineBaseElement({ +export const SpanElement = defineBaseElementWithRef({ type: 'span', displayName: 'Span', }); @@ -85,7 +85,7 @@ type TextAreaElementProps = | 'onCompositionEnd' | 'onKeyDown'; -export const TextAreaElement = defineBaseElement< +export const TextAreaElement = defineBaseElementWithRef< 'textarea', TextAreaElementProps >({ diff --git a/packages/react-auth/package.json b/packages/react-auth/package.json index 88408905a11..3cd1401efb6 100644 --- a/packages/react-auth/package.json +++ b/packages/react-auth/package.json @@ -49,7 +49,7 @@ "tslib": "^2.5.2" }, "peerDependencies": { - "aws-amplify": "^6.6.5", + "aws-amplify": "unstable", "react": "^16.14.0 || ^17.0 || ^18.0", "react-dom": "^16.14.0 || ^17.0 || ^18.0" }, diff --git a/packages/react-core-auth/package.json b/packages/react-core-auth/package.json index c6788299746..dca15061779 100644 --- a/packages/react-core-auth/package.json +++ b/packages/react-core-auth/package.json @@ -42,7 +42,7 @@ }, "peerDependencies": { "@aws-amplify/core": "*", - "aws-amplify": "^6.6.5", + "aws-amplify": "unstable", "react": "^16.14.0 || ^17.0 || ^18.0" }, "sideEffects": false diff --git a/packages/react-core-notifications/package.json b/packages/react-core-notifications/package.json index 7e5998ddabc..f82a77c0215 100644 --- a/packages/react-core-notifications/package.json +++ b/packages/react-core-notifications/package.json @@ -39,7 +39,7 @@ "@aws-amplify/ui-react-core": "3.0.30" }, "peerDependencies": { - "aws-amplify": "^6.6.5", + "aws-amplify": "unstable", "react": "^16.14.0 || ^17.0 || ^18.0" }, "sideEffects": false diff --git a/packages/react-core/package.json b/packages/react-core/package.json index 293b722aa4f..20b9e2673ae 100644 --- a/packages/react-core/package.json +++ b/packages/react-core/package.json @@ -47,7 +47,7 @@ "xstate": "^4.33.6" }, "peerDependencies": { - "aws-amplify": "^6.6.5", + "aws-amplify": "unstable", "react": "^16.14.0 || ^17.0 || ^18.0" }, "sideEffects": false diff --git a/packages/react-core/src/elements/ControlsContext.tsx b/packages/react-core/src/elements/ControlsContext.tsx new file mode 100644 index 00000000000..63d3cd908cd --- /dev/null +++ b/packages/react-core/src/elements/ControlsContext.tsx @@ -0,0 +1,89 @@ +import React from 'react'; +import { isComponent } from './utils'; + +/** + * @internal @unstable + */ +export interface Controls + extends Partial> {} + +/** + * @internal @unstable + */ +export const ControlsContext = React.createContext( + undefined +); + +/** + * @internal @unstable + * + * `ControlsProvider` provides the values contained in `ControlsContext` + * to consumers. `ControlsContext` lookup is handled directly + * by `Control` components returned by `withControls`. + * + * @example + * + * Add `ControlsContext` aware `Controls` components to a Connected + * Component: + * + * ```tsx + * const DataList = withControls(function DataList(data: T[]) { + * return data.map(ListItem); + * }, 'DataList'); + * + * const DataListControl = () => { + * const data = useData(); + * return ; + * } + * + * interface ComponentControls { + * DataList: typeof DataListControl; + * } + * + * function Component( + * controls?: T + * ) { + * function ConnectedComponent({ + * children, + * }: { children?: React.ReactNode }) { + * return ( + * + * {children} + * + * ); + * } + * + * return ConnectedComponent; + * } + * ``` + */ +export function ControlsProvider({ + controls, + ...props +}: { + children?: React.ReactNode; + controls?: T; +}): React.JSX.Element { + return ; +} + +/** + * @internal @unstable + * + * @note reference `ControlsProvider` for example usage + */ +export function withControls< + T extends React.ComponentType, + K extends keyof Controls, +>(Default: T, name: K): (props: React.ComponentProps) => React.JSX.Element { + const Component = (props: React.ComponentProps) => { + const Override = React.useContext(ControlsContext)?.[name]; + if (isComponent(Override)) { + return ; + } + return ; + }; + + Component.displayName = name; + return Component; +} diff --git a/packages/react-core/src/elements/ElementsContext.tsx b/packages/react-core/src/elements/ElementsContext.tsx index e610957f622..65e0d228417 100644 --- a/packages/react-core/src/elements/ElementsContext.tsx +++ b/packages/react-core/src/elements/ElementsContext.tsx @@ -16,7 +16,8 @@ export const ElementsContext = React.createContext( * * `ElementsProvider` provides the values contained in `ElementsContext` * to its `children`. `ElementsContext` lookup is handled directly - * by `BaseElement`components returned by `defineBaseElement`. + * by `BaseElement`components returned by `defineBaseElement` and + * `defineBaseElementWithRef`. * * @example * @@ -25,7 +26,7 @@ export const ElementsContext = React.createContext( * * ```tsx * // `BaseElement`, renders custom or default element defintion - * const ViewElement = defineBaseElement({ + * const ViewElement = defineBaseElementWithRef({ * displayName: "View", * type: "div", * }); diff --git a/packages/react-core/src/elements/__tests__/ControlsContext.spec.tsx b/packages/react-core/src/elements/__tests__/ControlsContext.spec.tsx new file mode 100644 index 00000000000..690621efb02 --- /dev/null +++ b/packages/react-core/src/elements/__tests__/ControlsContext.spec.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { render, renderHook } from '@testing-library/react'; + +import { + ControlsContext, + ControlsProvider, + withControls, +} from '../ControlsContext'; + +const ButtonElement = () =>