diff --git a/.env b/.env index f7e8676da..3f175f57a 100644 --- a/.env +++ b/.env @@ -20,7 +20,7 @@ LETS_ENCRYPT_PROVIDER= LETS_ENCRYPT_CA_SERVER=https://acme-staging-v02.api.letsencrypt.org/directory PHRASEA_DOMAIN="${PHRASEA_DOMAIN:-phrasea.local}" -DASHBOARD_URL=https://dashboard.${PHRASEA_DOMAIN}${HTTPS_PORT_PREFIX} +DASHBOARD_CLIENT_URL=https://dashboard.${PHRASEA_DOMAIN}${HTTPS_PORT_PREFIX} S3_ENDPOINT=https://minio.${PHRASEA_DOMAIN}${HTTPS_PORT_PREFIX} UPLOADER_API_URL=https://api-uploader.${PHRASEA_DOMAIN}${HTTPS_PORT_PREFIX} EXPOSE_API_URL=https://api-expose.${PHRASEA_DOMAIN}${HTTPS_PORT_PREFIX} @@ -51,6 +51,8 @@ APP_ENV=prod # Enables some features for debugging applications DEV_MODE=false +# Dashboard +DASHBOARD_CLIENT_ID=dashboard-app DISPLAY_SERVICES_MENU=true # Minio diff --git a/configurator/config/services.yaml b/configurator/config/services.yaml index 743cca60b..703268955 100644 --- a/configurator/config/services.yaml +++ b/configurator/config/services.yaml @@ -21,6 +21,7 @@ services: - databox - expose - uploader + - dashboard # makes classes in src/ available to be used as services # this creates a service per class whose id is the fully-qualified class name diff --git a/dashboard/client/config-compiler.js b/dashboard/client/config-compiler.js index 52ee823a5..efe8e8be4 100644 --- a/dashboard/client/config-compiler.js +++ b/dashboard/client/config-compiler.js @@ -1,4 +1,15 @@ (function (config, env) { + config = config || {}; + + const analytics = {}; + + if (env.MATOMO_URL) { + analytics.matomo = { + baseUrl: env.MATOMO_URL, + siteId: env.MATOMO_SITE_ID, + }; + } + const whiteList = [ 'DATABOX_API_URL', 'DATABOX_CLIENT_URL', @@ -33,8 +44,36 @@ } }); + function castBoolean(value) { + if (typeof value === 'boolean') { + return value; + } + + if (typeof value === 'string') { + return ['true', '1', 'on', 'y', 'yes'].includes( + value.toLowerCase() + ); + } + + return false; + } + + return { locales: config.available_locales, + autoConnectIdP: env.AUTO_CONNECT_IDP, + baseUrl: env.DASHBOARD_CLIENT_URL, + keycloakUrl: env.KEYCLOAK_URL, + realmName: env.KEYCLOAK_REALM_NAME, + clientId: env.CLIENT_ID, + devMode: castBoolean(env.DEV_MODE), + displayServicesMenu: castBoolean(env.DISPLAY_SERVICES_MENU), + dashboardBaseUrl: env.DASHBOARD_CLIENT_URL, + analytics, + appId: env.APP_ID || 'dashboard', + sentryDsn: env.SENTRY_DSN, + sentryEnvironment: env.SENTRY_ENVIRONMENT, + sentryRelease: env.SENTRY_RELEASE, env: e, }; }); diff --git a/dashboard/client/package.json b/dashboard/client/package.json index 80dde20b4..c105b7241 100644 --- a/dashboard/client/package.json +++ b/dashboard/client/package.json @@ -11,13 +11,19 @@ "preview": "vite preview" }, "dependencies": { + "@alchemy/api": "workspace:*", + "@alchemy/auth": "workspace:*", + "@alchemy/core": "workspace:*", + "@alchemy/react-auth": "workspace:*", "@alchemy/theme-editor": "workspace:*", - "@mui/material": "^5.15.0", + "@alchemy/phrasea-ui": "workspace:*", + "@mui/material": "^5.15.1", "@mui/icons-material": "^5.15.0", "react": "^18.2.0", "react-dom": "^18.2.0", "react-google-font-loader": "^1.1.0", - "vite-plugin-svgr": "^4.2.0" + "vite-plugin-svgr": "^4.2.0", + "react-i18next": "^13.5.0" }, "devDependencies": { "@types/node": "^18.8.5", diff --git a/dashboard/client/src/Dashboard.tsx b/dashboard/client/src/Dashboard.tsx new file mode 100644 index 000000000..d70932cac --- /dev/null +++ b/dashboard/client/src/Dashboard.tsx @@ -0,0 +1,136 @@ +import {Alert, Chip, Container, Grid, Typography, useMediaQuery, useTheme,} from '@mui/material'; +import Service from './Service'; +import ClientApp from './ClientApp.tsx'; +import config from './config.ts'; +import ApiIcon from '@mui/icons-material/Api'; +import SellIcon from '@mui/icons-material/Sell'; +import keycloakImg from './images/keycloak.png'; +import databoxImg from './images/databox.png'; +import uploaderImg from './images/uploader.png'; +import exposeImg from './images/expose.png'; +import notifyImg from './images/notify.png'; +import DashboardBar from "./DashboardBar"; +import {useKeycloakUser} from '@alchemy/react-auth' +import AdminPanelSettingsIcon from "@mui/icons-material/AdminPanelSettings"; + +type Props = {}; + +export default function Dashboard({}: Props) { + const theme = useTheme(); + const isLarge = useMediaQuery(theme.breakpoints.up('sm')); + const {user} = useKeycloakUser(); + + const { + DATABOX_API_URL, + EXPOSE_API_URL, + UPLOADER_API_URL, + NOTIFY_API_URL, + DATABOX_CLIENT_URL, + EXPOSE_CLIENT_URL, + UPLOADER_CLIENT_URL, + STACK_NAME, + DEV_MODE, + STACK_VERSION, + } = config.env; + + console.debug('config', config); + + return ( + + {isLarge && ( + + + {STACK_NAME} + {user && } + label={STACK_VERSION} + color={'info'} + />} + + + )} + + {user && isLarge && DEV_MODE && ( + + Developer Mode is enabled + + )} + + + , + href: `${config.keycloakUrl}/admin/master/console`, + title: `Master Admin`, + }, + ]} + /> + {DATABOX_API_URL && ( + + )} + {EXPOSE_API_URL && ( + + )} + {UPLOADER_API_URL && ( + + )} + {NOTIFY_API_URL && ( + , + href: NOTIFY_API_URL, + title: `API documentation of Notify`, + }, + ]} + /> + )} + + + ); +} diff --git a/dashboard/client/src/DashboardBar.tsx b/dashboard/client/src/DashboardBar.tsx new file mode 100644 index 000000000..e079b9b3c --- /dev/null +++ b/dashboard/client/src/DashboardBar.tsx @@ -0,0 +1,53 @@ +import AppBar from '@mui/material/AppBar'; +import Toolbar from '@mui/material/Toolbar'; +import {PropsWithChildren} from "react"; +import {useKeycloakUrls, useKeycloakUser} from '@alchemy/react-auth'; +import config from "./config.ts"; +import {keycloakClient} from "./lib/apiClient.ts"; +import MenuItem from "@mui/material/MenuItem"; +import Box from "@mui/material/Box"; +import {UserMenu} from '@alchemy/phrasea-ui'; +import {useTranslation} from "react-i18next"; + +type Props = PropsWithChildren<{}>; + +export default function DashboardBar({ + children +}: Props) { + const menuHeight = 42; + const {t} = useTranslation(); + const {getLoginUrl, getAccountUrl} = useKeycloakUrls({ + autoConnectIdP: config.autoConnectIdP, + keycloakClient, + }); + + const {user, logout} = useKeycloakUser(); + + return ( + + +
+ {children} +
+ + + + {!user ? ( + + {t('menu.sign_in', 'Sign in')} + + ) : ( + + )} + +
+
+ ); +} diff --git a/dashboard/client/src/Root.tsx b/dashboard/client/src/Root.tsx index 3c9f981ae..26d981b0e 100644 --- a/dashboard/client/src/Root.tsx +++ b/dashboard/client/src/Root.tsx @@ -1,118 +1,38 @@ -import {Alert, Chip, Container, Grid, Typography, useMediaQuery, useTheme} from '@mui/material'; -import Service from './Service'; -import ClientApp from './ClientApp.tsx'; -import config from './config.ts'; -import ApiIcon from "@mui/icons-material/Api"; -import SellIcon from '@mui/icons-material/Sell'; -import keycloakImg from './images/keycloak.png' -import databoxImg from './images/databox.png' -import uploaderImg from './images/uploader.png' -import exposeImg from './images/expose.png' -import notifyImg from './images/notify.png' +import {oauthClient} from './lib/apiClient'; +import {AuthenticationProvider, SessionExpireContainer, useAuthorizationCode} from '@alchemy/react-auth'; +import {FullPageLoader} from '@alchemy/phrasea-ui'; +import Dashboard from "./Dashboard.tsx"; type Props = {}; export default function Root({}: Props) { const { - DATABOX_API_URL, - EXPOSE_API_URL, - UPLOADER_API_URL, - NOTIFY_API_URL, - KEYCLOAK_URL, - DATABOX_CLIENT_URL, - EXPOSE_CLIENT_URL, - UPLOADER_CLIENT_URL, - STACK_NAME, - DEV_MODE, - STACK_VERSION, - } = config.env; + error, + hasCode, + } = useAuthorizationCode({ + oauthClient, + allowNoCode: true, + navigate: (path, {replace} = {}) => { + if (replace) { + document.location.replace(path); + } else { + document.location.href = path + } + }, + successUri: '/' + }); - console.debug('config.env', config.env); - - const theme = useTheme(); - const isLarge = useMediaQuery(theme.breakpoints.up('sm')); + if (error) { + return
+ {error.toString()} +
+ } return ( - - {isLarge && - {STACK_NAME} - } - label={STACK_VERSION} - /> - } - - {isLarge && DEV_MODE && Developer Mode is enabled} - - - - {DATABOX_API_URL && ( - - )} - {EXPOSE_API_URL && ( - - )} - {UPLOADER_API_URL && ( - - )} - {NOTIFY_API_URL && ( - , - href: NOTIFY_API_URL, - title: `API documentation of Notify`, - }, - ]} - /> - )} - - + + {hasCode && } + + + ); } diff --git a/dashboard/client/src/Service.tsx b/dashboard/client/src/Service.tsx index 26da3c471..dcb80a785 100644 --- a/dashboard/client/src/Service.tsx +++ b/dashboard/client/src/Service.tsx @@ -59,7 +59,6 @@ export default function Service({ backgroundColor: theme.palette.background.default, })} image={logo} - title="green iguana" /> @@ -75,7 +74,7 @@ export default function Service({ display: { xs: 'none', md: 'block', - } + }, }} > {description} diff --git a/dashboard/client/src/config.ts b/dashboard/client/src/config.ts index 5812f2328..3c6cb8674 100644 --- a/dashboard/client/src/config.ts +++ b/dashboard/client/src/config.ts @@ -1,7 +1,8 @@ +import {WindowConfig} from '@alchemy/core'; + declare global { interface Window { config: { - locales: string[]; env: { DATABOX_API_URL: string; DATABOX_CLIENT_URL: string; @@ -10,7 +11,6 @@ declare global { ELASTICHQ_URL: string; EXPOSE_API_URL: string; EXPOSE_CLIENT_URL: string; - KEYCLOAK_URL: string; MAILHOG_URL: string; MATOMO_URL: string; NOTIFY_API_URL: string; @@ -27,10 +27,12 @@ declare global { UPLOADER_CLIENT_URL: string; ZIPPY_URL: string; }; - }; + } & WindowConfig; } } const config = window.config; +config.appName = 'dashboard'; + export default config; diff --git a/dashboard/client/src/index.tsx b/dashboard/client/src/index.tsx index 2c02dce19..e4f905997 100644 --- a/dashboard/client/src/index.tsx +++ b/dashboard/client/src/index.tsx @@ -1,16 +1,15 @@ import ReactDOM from 'react-dom/client'; -import Root from './Root.tsx'; import React from 'react'; import {CssBaseline, GlobalStyles, responsiveFontSizes} from '@mui/material'; import {ThemeEditorProvider} from '@alchemy/theme-editor'; -import {scrollbarWidth, theme} from "./theme.ts"; +import {scrollbarWidth, theme} from './theme.ts'; +import Root from "./Root.tsx"; ReactDOM.createRoot(document.getElementById('root')!).render( responsiveFontSizes(theme, { - })} + transformTheme={theme => responsiveFontSizes(theme, {})} > - + ); diff --git a/dashboard/client/src/lib/apiClient.ts b/dashboard/client/src/lib/apiClient.ts new file mode 100644 index 000000000..f094a7bc0 --- /dev/null +++ b/dashboard/client/src/lib/apiClient.ts @@ -0,0 +1,17 @@ +import {configureClientAuthentication, KeycloakClient} from '@alchemy/auth'; +import {createHttpClient} from '@alchemy/api'; + +import config from '../config'; + +export const keycloakClient = new KeycloakClient({ + clientId: config.clientId, + baseUrl: config.keycloakUrl, + realm: config.realmName, +}); +export const oauthClient = keycloakClient.client; + +const apiClient = createHttpClient(window.config.baseUrl); + +configureClientAuthentication(apiClient, oauthClient); + +export default apiClient; diff --git a/dashboard/client/src/theme.ts b/dashboard/client/src/theme.ts index 776e664e5..da43a49bd 100644 --- a/dashboard/client/src/theme.ts +++ b/dashboard/client/src/theme.ts @@ -1,10 +1,10 @@ -import {ThemeOptions} from "@mui/material"; +import {ThemeOptions} from '@mui/material'; export const theme: ThemeOptions = { typography: { fontFamily: "'Montserrat', sans-serif", h1: { - fontSize: '3rem', + fontSize: '2rem', fontWeight: 600, }, h2: { diff --git a/databox/api/migrations/Version20231221125408.php b/databox/api/migrations/Version20231221125408.php new file mode 100644 index 000000000..01c6d6638 --- /dev/null +++ b/databox/api/migrations/Version20231221125408.php @@ -0,0 +1,34 @@ +addSql('ALTER TABLE rendition_definition ADD key VARCHAR(150) DEFAULT NULL'); + $this->addSql('CREATE UNIQUE INDEX uniq_rend_def_ws_key ON rendition_definition (workspace_id, key)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE SCHEMA public'); + $this->addSql('DROP INDEX uniq_rend_def_ws_key'); + $this->addSql('ALTER TABLE rendition_definition DROP key'); + } +} diff --git a/databox/api/src/Api/InputTransformer/RenditionDefinitionInputTransformer.php b/databox/api/src/Api/InputTransformer/RenditionDefinitionInputTransformer.php new file mode 100644 index 000000000..e49e1121d --- /dev/null +++ b/databox/api/src/Api/InputTransformer/RenditionDefinitionInputTransformer.php @@ -0,0 +1,92 @@ +workspace) { + $workspace = $data->workspace; + } + + if ($isNew) { + if (!$workspace instanceof Workspace) { + throw new BadRequestHttpException('Missing workspace'); + } + + if ($data->key) { + $rendDef = $this->em->getRepository(RenditionDefinition::class) + ->findOneBy([ + 'key' => $data->key, + 'workspace' => $workspace->getId() + ]); + + if ($rendDef) { + $isNew = false; + $object = $rendDef; + } + } + } + + if ($isNew) { + $object->setWorkspace($workspace); + $object->setKey($data->key); + } + + if (null !== $data->name) { + $object->setName($data->name); + } + if (null !== $data->class) { + $object->setClass($data->class); + } + + if (null !== $data->download) { + $object->setDownload($data->download); + } + if (null !== $data->pickSourceFile) { + $object->setPickSourceFile($data->pickSourceFile); + } + if (null !== $data->useAsOriginal) { + $object->setUseAsOriginal($data->useAsOriginal); + } + if (null !== $data->useAsPreview) { + $object->setUseAsPreview($data->useAsPreview); + } + if (null !== $data->useAsThumbnail) { + $object->setUseAsThumbnail($data->useAsThumbnail); + } + if (null !== $data->useAsThumbnailActive) { + $object->setUseAsThumbnailActive($data->useAsThumbnailActive); + } + if (null !== $data->definition) { + $object->setDefinition($data->definition); + } + if (null !== $data->priority) { + $object->setPriority($data->priority); + } + + return $object; + } +} diff --git a/databox/api/src/Api/Model/Input/RenditionDefinitionInput.php b/databox/api/src/Api/Model/Input/RenditionDefinitionInput.php new file mode 100644 index 000000000..cc851bf08 --- /dev/null +++ b/databox/api/src/Api/Model/Input/RenditionDefinitionInput.php @@ -0,0 +1,85 @@ + [RenditionDefinition::GROUP_WRITE], ], + input: RenditionDefinitionInput::class, order: ['priority' => 'DESC'], provider: RenditionDefinitionCollectionProvider::class, )] #[ORM\Table] #[ORM\Index(columns: ['workspace_id', 'name'], name: 'rend_def_ws_name')] +#[ORM\UniqueConstraint(name: 'uniq_rend_def_ws_key', columns: ['workspace_id', 'key'])] #[ORM\Entity] class RenditionDefinition extends AbstractUuidEntity implements \Stringable { @@ -90,6 +93,12 @@ class RenditionDefinition extends AbstractUuidEntity implements \Stringable #[Groups(['_'])] protected ?Workspace $workspace = null; + /** + * Unique key by workspace. Used to prevent duplicates. + */ + #[ORM\Column(type: Types::STRING, length: 150, nullable: true)] + private ?string $key = null; + #[Groups([RenditionDefinition::GROUP_LIST, RenditionDefinition::GROUP_READ, RenditionDefinition::GROUP_WRITE])] #[ORM\Column(type: Types::STRING, length: 80)] private ?string $name = null; @@ -255,4 +264,14 @@ public function setPickSourceFile(bool $pickSourceFile): void { $this->pickSourceFile = $pickSourceFile; } + + public function getKey(): ?string + { + return $this->key; + } + + public function setKey(?string $key): void + { + $this->key = $key; + } } diff --git a/databox/api/src/Workspace/WorkspaceDuplicateManager.php b/databox/api/src/Workspace/WorkspaceDuplicateManager.php index af335f39e..6d659e67f 100644 --- a/databox/api/src/Workspace/WorkspaceDuplicateManager.php +++ b/databox/api/src/Workspace/WorkspaceDuplicateManager.php @@ -62,6 +62,7 @@ private function copyRenditionDefinitions(Workspace $from, Workspace $to): void $i->setWorkspace($to); $i->setClass($classMap[$item->getClass()->getId()]); $i->setPriority($item->getPriority()); + $i->setKey($item->getKey()); $i->setUseAsOriginal($item->isUseAsOriginal()); $i->setUseAsPreview($item->isUseAsPreview()); $i->setUseAsThumbnail($item->isUseAsThumbnail()); diff --git a/databox/client/config-compiler.js b/databox/client/config-compiler.js index 50ea74c68..15d4e2518 100644 --- a/databox/client/config-compiler.js +++ b/databox/client/config-compiler.js @@ -61,7 +61,7 @@ devMode: castBoolean(env.DEV_MODE), requestSignatureTtl: env.S3_REQUEST_SIGNATURE_TTL, displayServicesMenu: castBoolean(env.DISPLAY_SERVICES_MENU), - dashboardBaseUrl: env.DASHBOARD_URL, + dashboardBaseUrl: env.DASHBOARD_CLIENT_URL, allowedTypes: normalizeTypes(env.ALLOWED_FILE_TYPES), analytics, appId: env.APP_ID || 'databox', diff --git a/databox/client/package.json b/databox/client/package.json index 042e2e49e..a498ca061 100644 --- a/databox/client/package.json +++ b/databox/client/package.json @@ -11,6 +11,7 @@ "@alchemy/theme-editor": "workspace:*", "@alchemy/visual-workflow": "workspace:*", "@alchemy/react-hooks": "workspace:*", + "@alchemy/phrasea-ui": "workspace:*", "@alchemy/navigation": "workspace:*", "@dnd-kit/core": "^6.0.5", "@dnd-kit/sortable": "^7.0.1", @@ -18,8 +19,8 @@ "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", "@mui/icons-material": "^5.15.0", - "@mui/lab": "^5.0.0-alpha.156", - "@mui/material": "^5.15.0", + "@mui/lab": "^5.0.0-alpha.157", + "@mui/material": "^5.15.1", "@toast-ui/react-image-editor": "^3.15.2", "ace-builds": "^1.14.0", "axios": "^1.6.2", diff --git a/databox/client/src/components/App.tsx b/databox/client/src/components/App.tsx index 008824faa..7d2333c3e 100644 --- a/databox/client/src/components/App.tsx +++ b/databox/client/src/components/App.tsx @@ -83,8 +83,11 @@ const AppProxy = React.memo(() => { export default function App() { const {logout} = useAuth(); const onError = useRequestErrorHandler({ - logout: (redirectPathAfterLogin) => { - logout(redirectPathAfterLogin, true); + logout: redirectPathAfterLogin => { + logout({ + redirectPathAfterLogin, + quiet: true, + }); }, }); diff --git a/databox/client/src/components/Dialog/Collection/EditCollection.tsx b/databox/client/src/components/Dialog/Collection/EditCollection.tsx index 535c965bb..63a693d23 100644 --- a/databox/client/src/components/Dialog/Collection/EditCollection.tsx +++ b/databox/client/src/components/Dialog/Collection/EditCollection.tsx @@ -37,11 +37,7 @@ export default function EditCollection({data, onClose, minHeight}: Props) { }, }); - const { - submitting, - remoteErrors, - forbidNavigation, - } = usedFormSubmit; + const {submitting, remoteErrors, forbidNavigation} = usedFormSubmit; useInRouterDirtyFormPrompt(t, forbidNavigation); const formId = 'edit-collection'; diff --git a/databox/client/src/components/Dialog/Tabbed/FormTab.tsx b/databox/client/src/components/Dialog/Tabbed/FormTab.tsx index e5f176ff7..12e54aa28 100644 --- a/databox/client/src/components/Dialog/Tabbed/FormTab.tsx +++ b/databox/client/src/components/Dialog/Tabbed/FormTab.tsx @@ -6,7 +6,10 @@ import {LoadingButton} from '@mui/lab'; import SaveIcon from '@mui/icons-material/Save'; import RemoteErrors from '../../Form/RemoteErrors'; import {useTranslation} from 'react-i18next'; -import {useInRouterDirtyFormPrompt, useOutsideRouterDirtyFormPrompt} from '@alchemy/navigation'; +import { + useInRouterDirtyFormPrompt, + useOutsideRouterDirtyFormPrompt, +} from '@alchemy/navigation'; type Props = PropsWithChildren<{ loading: boolean; diff --git a/databox/client/src/components/Dialog/Tabbed/TabbedDialog.tsx b/databox/client/src/components/Dialog/Tabbed/TabbedDialog.tsx index c0ae39191..f9c689413 100644 --- a/databox/client/src/components/Dialog/Tabbed/TabbedDialog.tsx +++ b/databox/client/src/components/Dialog/Tabbed/TabbedDialog.tsx @@ -87,12 +87,13 @@ export default function TabbedDialog

({ ); })} - {currentTab && React.createElement(currentTab.component, { - ...rest, - ...currentTab.props, - onClose: closeModal, - minHeight, - })} + {currentTab && + React.createElement(currentTab.component, { + ...rest, + ...currentTab.props, + onClose: closeModal, + minHeight, + })} )} diff --git a/databox/client/src/components/Dialog/Workspace/DefinitionManager.tsx b/databox/client/src/components/Dialog/Workspace/DefinitionManager.tsx index c1b8dfe24..3b9283528 100644 --- a/databox/client/src/components/Dialog/Workspace/DefinitionManager.tsx +++ b/databox/client/src/components/Dialog/Workspace/DefinitionManager.tsx @@ -186,12 +186,7 @@ export default function DefinitionManager({ }, }); - const { - submitting, - remoteErrors, - forbidNavigation, - reset, - } = usedFormSubmit; + const {submitting, remoteErrors, forbidNavigation, reset} = usedFormSubmit; React.useEffect(() => { if (item && 'new' !== item) { diff --git a/databox/client/src/components/Dialog/Workspace/RenditionDefinitionManager.tsx b/databox/client/src/components/Dialog/Workspace/RenditionDefinitionManager.tsx index f51220374..d42375af5 100644 --- a/databox/client/src/components/Dialog/Workspace/RenditionDefinitionManager.tsx +++ b/databox/client/src/components/Dialog/Workspace/RenditionDefinitionManager.tsx @@ -18,7 +18,7 @@ import RenditionClassSelect from '../../Form/RenditionClassSelect'; import CheckboxWidget from '../../Form/CheckboxWidget'; import apiClient from '../../../api/api-client'; import {toast} from 'react-toastify'; -import React from "react"; +import React from 'react'; function Item({ data, diff --git a/databox/client/src/components/Dialog/Workspace/WorkspaceDialog.tsx b/databox/client/src/components/Dialog/Workspace/WorkspaceDialog.tsx index b7c0661fb..a6bd3ebf8 100644 --- a/databox/client/src/components/Dialog/Workspace/WorkspaceDialog.tsx +++ b/databox/client/src/components/Dialog/Workspace/WorkspaceDialog.tsx @@ -15,9 +15,6 @@ import RenditionClassManager from './RenditionClassManager'; import RenditionDefinitionManager from './RenditionDefinitionManager'; import InfoWorkspace from './InfoWorkspace'; import {modalRoutes} from '../../../routes.ts'; -import {useForceLogin} from '@alchemy/react-auth'; -import config from "../../../config.ts"; -import {keycloakClient} from "../../../api/api-client.ts"; type Props = {}; @@ -27,11 +24,6 @@ export default function WorkspaceDialog({}: Props) { const [data, setData] = useState(); - useForceLogin({ - keycloakClient, - autoConnectIdP: config.autoConnectIdP, - }); - useEffect(() => { getWorkspace(id!).then(c => setData(c)); }, [id]); diff --git a/databox/client/src/components/Layout/ChangeTheme.tsx b/databox/client/src/components/Layout/ChangeTheme.tsx index 81d122a65..f8f84d47f 100644 --- a/databox/client/src/components/Layout/ChangeTheme.tsx +++ b/databox/client/src/components/Layout/ChangeTheme.tsx @@ -15,9 +15,7 @@ import {StackedModalProps, useModals} from '@alchemy/navigation'; type Props = {} & StackedModalProps; -export default function ChangeTheme({ - open, -}: Props) { +export default function ChangeTheme({open}: Props) { const {t} = useTranslation(); const prefContext = useContext(UserPreferencesContext); const {preferences, updatePreference} = prefContext; diff --git a/databox/client/src/components/Layout/MainAppBar.tsx b/databox/client/src/components/Layout/MainAppBar.tsx index bcbe0baf7..3ceac1a8e 100644 --- a/databox/client/src/components/Layout/MainAppBar.tsx +++ b/databox/client/src/components/Layout/MainAppBar.tsx @@ -1,22 +1,16 @@ -import * as React from 'react'; import {useContext} from 'react'; import AppBar from '@mui/material/AppBar'; import Box from '@mui/material/Box'; import Toolbar from '@mui/material/Toolbar'; import IconButton from '@mui/material/IconButton'; import Typography from '@mui/material/Typography'; -import Menu from '@mui/material/Menu'; import MenuIcon from '@mui/icons-material/Menu'; import Container from '@mui/material/Container'; -import Avatar from '@mui/material/Avatar'; -import Tooltip from '@mui/material/Tooltip'; import MenuItem from '@mui/material/MenuItem'; import {useTranslation} from 'react-i18next'; -import {Divider, ListItemIcon, ListItemText} from '@mui/material'; -import LogoutIcon from '@mui/icons-material/Logout'; +import {ListItemIcon, ListItemText} from '@mui/material'; import {SearchContext} from '../Media/Search/SearchContext'; import ColorLensIcon from '@mui/icons-material/ColorLens'; -import AccountBoxIcon from '@mui/icons-material/AccountBox'; import {zIndex} from '../../themes/zIndex'; import {useKeycloakUrls} from '@alchemy/react-auth'; import {ThemeEditorContext} from '@alchemy/theme-editor'; @@ -25,8 +19,9 @@ import {keycloakClient} from '../../api/api-client.ts'; import {useUser} from '../../lib/auth.ts'; import {DashboardMenu} from '@alchemy/react-ps'; import {useModals} from '@alchemy/navigation'; -import ChangeTheme from "./ChangeTheme.tsx"; -import ThemeEditor from "./ThemeEditor.tsx"; +import ChangeTheme from './ChangeTheme.tsx'; +import ThemeEditor from './ThemeEditor.tsx'; +import {UserMenu} from '@alchemy/phrasea-ui'; export const menuHeight = 42; @@ -39,10 +34,7 @@ export default function MainAppBar({onToggleLeftPanel}: Props) { const {t} = useTranslation(); const {openModal} = useModals(); const themeEditorContext = useContext(ThemeEditorContext); - const userContext = useUser(); - const [anchorElUser, setAnchorElUser] = React.useState( - null - ); + const {user, logout} = useUser(); const searchContext = useContext(SearchContext); const {getAccountUrl, getLoginUrl} = useKeycloakUrls({ keycloakClient, @@ -51,15 +43,7 @@ export default function MainAppBar({onToggleLeftPanel}: Props) { const onTitleClick = () => searchContext.selectWorkspace(undefined, undefined, true); - const handleOpenUserMenu = (event: React.MouseEvent) => { - setAnchorElUser(event.currentTarget); - }; - - const handleCloseUserMenu = () => { - setAnchorElUser(null); - }; - - const username = userContext.user?.username; + const username = user?.username; return (

) : ( - <> - - - - {( - username[0] || 'U' - ).toUpperCase()} - - - - - - - - - - + [ { openModal(ChangeTheme); - handleCloseUserMenu(); + closeMenu(); }} > @@ -215,18 +153,24 @@ export default function MainAppBar({onToggleLeftPanel}: Props) { 'Change theme' )} /> - + , { - openModal(ThemeEditor, {}, { - forwardedContexts: [ - { - context: ThemeEditorContext, - value: themeEditorContext, - } - ] - }); - handleCloseUserMenu(); + openModal( + ThemeEditor, + {}, + { + forwardedContexts: [ + { + context: + ThemeEditorContext, + value: themeEditorContext, + }, + ], + } + ); + closeMenu(); }} > @@ -239,39 +183,22 @@ export default function MainAppBar({onToggleLeftPanel}: Props) { )} /> - - - userContext.logout!() - } - > - - - - - - - + ]} + /> )} {config.displayServicesMenu && (
- +
)} diff --git a/databox/client/src/components/Layout/ThemeEditor.tsx b/databox/client/src/components/Layout/ThemeEditor.tsx index 837c700b9..b652f5ae0 100644 --- a/databox/client/src/components/Layout/ThemeEditor.tsx +++ b/databox/client/src/components/Layout/ThemeEditor.tsx @@ -1,24 +1,17 @@ -import AppDialog from "./AppDialog.tsx"; +import AppDialog from './AppDialog.tsx'; import {StackedModalProps, useModals} from '@alchemy/navigation'; import {MuiThemeEditor} from '@alchemy/theme-editor'; type Props = {} & StackedModalProps; -export default function ThemeEditor({ - open, - modalIndex, -}: Props) { +export default function ThemeEditor({open, modalIndex}: Props) { const {closeModal} = useModals(); - const onClose= () => closeModal(); + const onClose = () => closeModal(); - return - - + return ( + + + + ); } diff --git a/databox/client/src/components/Media/Asset/AssetContextMenu.tsx b/databox/client/src/components/Media/Asset/AssetContextMenu.tsx index 5b210b420..802f01b53 100644 --- a/databox/client/src/components/Media/Asset/AssetContextMenu.tsx +++ b/databox/client/src/components/Media/Asset/AssetContextMenu.tsx @@ -22,7 +22,7 @@ import SaveAsButton from './Actions/SaveAsButton'; import {useNavigateToModal} from '../../Routing/ModalLink'; import SaveIcon from '@mui/icons-material/Save'; import {modalRoutes} from '../../../routes.ts'; -import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown"; +import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown'; type Props = { anchorPosition: PopoverPosition; diff --git a/databox/client/src/components/Media/Collection/CreateCollection.tsx b/databox/client/src/components/Media/Collection/CreateCollection.tsx index 958390a8a..98fb143e2 100644 --- a/databox/client/src/components/Media/Collection/CreateCollection.tsx +++ b/databox/client/src/components/Media/Collection/CreateCollection.tsx @@ -9,7 +9,7 @@ import {CollectionChip, WorkspaceChip} from '../../Ui/Chips'; import {StackedModalProps, useModals} from '@alchemy/navigation'; import {OnCollectionEdit} from '../../Dialog/Collection/EditCollection'; import React from 'react'; -import {useDirtyFormPromptOutsideRouter} from "../../Dialog/Tabbed/FormTab.tsx"; +import {useDirtyFormPromptOutsideRouter} from '../../Dialog/Tabbed/FormTab.tsx'; type Props = { parent?: string; @@ -58,11 +58,7 @@ export default function CreateCollection({ }, }); - const { - submitting, - remoteErrors, - forbidNavigation, - } = usedFormSubmit; + const {submitting, remoteErrors, forbidNavigation} = usedFormSubmit; useDirtyFormPromptOutsideRouter(forbidNavigation); const formId = 'create-collection'; @@ -96,10 +92,7 @@ export default function CreateCollection({ errors={remoteErrors} open={open} > - + ); } diff --git a/databox/client/src/components/Media/Search/SelectionActions.tsx b/databox/client/src/components/Media/Search/SelectionActions.tsx index 8db24c604..3ac206565 100644 --- a/databox/client/src/components/Media/Search/SelectionActions.tsx +++ b/databox/client/src/components/Media/Search/SelectionActions.tsx @@ -268,32 +268,37 @@ export default function SelectionActions({layout, onLayoutChange}: Props) { : t('asset_actions.select_all', 'Select all') } > - - - + })} + onClick={toggleSelectAll} + > + + + + + - + + + - - - {children} - - + return ( + <> + + {children} + + ); } diff --git a/databox/client/src/components/Routing/RouteProxy.tsx b/databox/client/src/components/Routing/RouteProxy.tsx index a63f909ce..a05f80cc2 100644 --- a/databox/client/src/components/Routing/RouteProxy.tsx +++ b/databox/client/src/components/Routing/RouteProxy.tsx @@ -16,8 +16,8 @@ export default function RouteProxy({ if (!isPublic && !isAuthenticated()) { document.location.href = getLoginUrl(); - return null + return null; } - return + return ; } diff --git a/databox/client/src/components/Upload/UploadModal.tsx b/databox/client/src/components/Upload/UploadModal.tsx index f5e8abcae..77cc63d5e 100644 --- a/databox/client/src/components/Upload/UploadModal.tsx +++ b/databox/client/src/components/Upload/UploadModal.tsx @@ -23,7 +23,8 @@ import { import {getBatchActions} from '../Media/Asset/Attribute/BatchActions'; import { StackedModalProps, - useModals, useOutsideRouterDirtyFormPrompt, + useModals, + useOutsideRouterDirtyFormPrompt, } from '@alchemy/navigation'; import {Privacy} from '../../api/privacy.ts'; import {Asset} from '../../types.ts'; diff --git a/databox/client/src/components/User/Preferences/UserPreferencesProvider.tsx b/databox/client/src/components/User/Preferences/UserPreferencesProvider.tsx index 7976f367f..99953a9fc 100644 --- a/databox/client/src/components/User/Preferences/UserPreferencesProvider.tsx +++ b/databox/client/src/components/User/Preferences/UserPreferencesProvider.tsx @@ -8,7 +8,7 @@ import { import {getUserPreferences, putUserPreferences} from '../../../api/user'; import {createCachedThemeOptions} from '../../../lib/theme'; import {CssBaseline, GlobalStyles} from '@mui/material'; -import {useAuth} from '@alchemy/react-auth'; +import {useKeycloakUser} from '@alchemy/react-auth'; import {ThemeEditorProvider} from '@alchemy/theme-editor'; const sessionStorageKey = 'userPrefs'; @@ -29,7 +29,7 @@ export default function UserPreferencesProvider({children}: Props) { const [preferences, setPreferences] = React.useState( getFromStorage() ); - const {tokens} = useAuth(); + const {user} = useKeycloakUser(); const updatePreference = React.useCallback( (name, value) => { @@ -42,7 +42,7 @@ export default function UserPreferencesProvider({children}: Props) { newPrefs[name] = value; } - if (tokens) { + if (user) { putUserPreferences(name, newPrefs[name]); } @@ -54,18 +54,18 @@ export default function UserPreferencesProvider({children}: Props) { return newPrefs; }); }, - [tokens] + [user] ); React.useEffect(() => { - if (tokens) { + if (user) { getUserPreferences().then(r => setPreferences({ ...r, }) ); } - }, [tokens]); + }, [user?.id]); const value = React.useMemo(() => { return { @@ -79,7 +79,9 @@ export default function UserPreferencesProvider({children}: Props) { return ( - + ); diff --git a/databox/client/src/lib/theme.ts b/databox/client/src/lib/theme.ts index f2e00e470..4a41dc58b 100644 --- a/databox/client/src/lib/theme.ts +++ b/databox/client/src/lib/theme.ts @@ -12,5 +12,9 @@ export function createCachedThemeOptions(name: ThemeName): ThemeOptions { return themeCache[name]; } - return (themeCache[name] = mergeDeep({}, baseTheme, themes[name]) as ThemeOptions); + return (themeCache[name] = mergeDeep( + {}, + baseTheme, + themes[name] + ) as ThemeOptions); } diff --git a/databox/indexer/package.json b/databox/indexer/package.json index abd6034e4..045be7800 100644 --- a/databox/indexer/package.json +++ b/databox/indexer/package.json @@ -10,7 +10,8 @@ "scripts": { "console": "node dist/console.mjs", "dev": "nodemon", - "build": "rimraf ./dist && vite build", + "validate": "tsc -p ./tsconfig.check.json", + "build": "pnpm validate && rimraf ./dist && vite build", "test": "jest", "sync-databox-types": "generate-api-platform-client --generator typescript http://databox-api src/", "format": "prettier --ignore-path .gitignore --write \"**/*.+(js|ts|json|cjs|tsx|jsx)\"", diff --git a/databox/indexer/src/alternateUrl.ts b/databox/indexer/src/alternateUrl.ts index 759fba95f..e5c9f4a1b 100644 --- a/databox/indexer/src/alternateUrl.ts +++ b/databox/indexer/src/alternateUrl.ts @@ -18,8 +18,8 @@ export function getAlternateUrls( return alternateUrls.map((c): AlternateUrl => { return { type: c.name, - url: c.pathPattern.replace(/\${(.+)}/g, (_m, m1) => { - return dict[m1]; + url: c.pathPattern.replace(/\${(.+)}/g, (_m, m1: string) => { + return dict[m1 as keyof typeof dict] as string; }), }; }); diff --git a/databox/indexer/src/command/commandUtil.ts b/databox/indexer/src/command/commandUtil.ts new file mode 100644 index 000000000..813a58f49 --- /dev/null +++ b/databox/indexer/src/command/commandUtil.ts @@ -0,0 +1,8 @@ +import {setLogLevel} from "../lib/logger"; +import {CommandCommonOptions} from "../types"; + +export function applyCommonOptions(opts: O): void { + if (opts.debug) { + setLogLevel('debug'); + } +} diff --git a/databox/indexer/src/command/index.ts b/databox/indexer/src/command/index.ts index c6b01db98..f9a3b1f38 100644 --- a/databox/indexer/src/command/index.ts +++ b/databox/indexer/src/command/index.ts @@ -3,16 +3,19 @@ import {createLogger} from '../lib/logger.js'; import {indexers} from '../indexers.js'; import {getLocation} from '../locations.js'; import {consume} from '../databox/entrypoint.js'; -import {runServer} from "../server"; +import {runServer} from '../server'; +import {CommandCommonOptions} from "../types"; +import {applyCommonOptions} from "./commandUtil"; export type IndexOptions = { createNewWorkspace?: boolean; -}; +} & CommandCommonOptions; export default async function indexCommand( locationName: string, options: IndexOptions ) { + applyCommonOptions(options); const location = getLocation(locationName); const databoxLogger = createLogger('databox'); diff --git a/databox/indexer/src/command/watch.ts b/databox/indexer/src/command/watch.ts index c20685300..b13f132c9 100644 --- a/databox/indexer/src/command/watch.ts +++ b/databox/indexer/src/command/watch.ts @@ -1,15 +1,16 @@ import {createDataboxClientFromConfig} from '../databox/client.js'; import {createLogger} from '../lib/logger.js'; -import {runServer} from "../server"; -import {IndexLocation} from "../types/config"; -import {getConfig} from "../configLoader"; -import {watchers} from "../watchers"; +import {runServer} from '../server'; +import {IndexLocation} from '../types/config'; +import {getConfig} from '../configLoader'; +import {watchers} from '../watchers'; +import {CommandCommonOptions} from "../types"; +import {applyCommonOptions} from "./commandUtil"; -export type WatchOptions = { -}; +export type WatchOptions = {} & CommandCommonOptions; export default async function watchCommand(options: WatchOptions) { - + applyCommonOptions(options); const mainLogger = createLogger('app'); const databoxLogger = createLogger('databox'); @@ -28,5 +29,4 @@ export default async function watchCommand(options: WatchOptions) { }); runServer(mainLogger); - } diff --git a/databox/indexer/src/configLoader.ts b/databox/indexer/src/configLoader.ts index 1f409b56f..4acee3f11 100644 --- a/databox/indexer/src/configLoader.ts +++ b/databox/indexer/src/configLoader.ts @@ -10,7 +10,7 @@ function loadConfig(): object { } function replaceEnv(str: string): string | boolean | number | undefined { - let transform; + let transform: string | undefined; let hasEnv = false; let result: string | undefined = str.replace( /%env\(([^^)]+)\)%/g, @@ -62,8 +62,8 @@ function parseConfig(config: any): any { if (Array.isArray(config)) { return config.map(parseConfig); } else { - const sub = {}; - Object.keys(config).forEach(k => { + const sub: Record = {}; + Object.keys(config).forEach((k: string) => { sub[k] = parseConfig(config[k]); }); return sub; @@ -84,10 +84,11 @@ export function getConfig( let p = root; for (let i = 0; i < parts.length; ++i) { - const k = parts[i]; + const k = parts[i] as string; if (!p.hasOwnProperty(k)) { return defaultValue; } + // @ts-expect-error any p = p[parts[i]]; } diff --git a/databox/indexer/src/console.ts b/databox/indexer/src/console.ts index 571f27770..020e8c2e5 100644 --- a/databox/indexer/src/console.ts +++ b/databox/indexer/src/console.ts @@ -1,14 +1,14 @@ -import {Command} from 'commander'; +import {Command, Option} from 'commander'; import indexCommand from './command/index.js'; -import listCommand from "./command/list"; -import watchCommand from "./command/watch"; +import listCommand from './command/list'; +import watchCommand from './command/watch'; const program = new Command(); -program - .name('indexer') - .description('Databox Indexer') - .version('1.0.0'); +program.name('console').description('Databox Indexer').version('1.0.0'); + +const debugOption = new Option('--debug', 'Debug mode') + .default(false); program .command('index') @@ -19,21 +19,16 @@ program 'Remove existing workspace and create a new empty one', false ) + .addOption(debugOption) .action(indexCommand); program .command('watch') .description('Watch locations') - .option( - '-l, --location', - 'List locations to watch', - false - ) + .option('-l, --location', 'List locations to watch', false) + .addOption(debugOption) .action(watchCommand); -program - .command('list') - .description('List locations') - .action(listCommand); +program.command('list').description('List locations').action(listCommand); program.parse(); diff --git a/databox/indexer/src/databox/client.ts b/databox/indexer/src/databox/client.ts index 593e5dd3c..02728b1bd 100644 --- a/databox/indexer/src/databox/client.ts +++ b/databox/indexer/src/databox/client.ts @@ -191,7 +191,7 @@ export class DataboxClient { return r; } - async createRenditionClass(data): Promise { + async createRenditionClass(data: object): Promise { const res = await this.client.post(`/rendition-classes`, data); return res.data.id; @@ -207,7 +207,7 @@ export class DataboxClient { return res.data['hydra:member']; } - async createRenditionDefinition(data): Promise { + async createRenditionDefinition(data: object): Promise { await this.client.post(`/rendition-definitions`, data); } diff --git a/databox/indexer/src/handlers/fs/shared.ts b/databox/indexer/src/handlers/fs/shared.ts index 6d95855a7..96ca0c74c 100644 --- a/databox/indexer/src/handlers/fs/shared.ts +++ b/databox/indexer/src/handlers/fs/shared.ts @@ -29,7 +29,10 @@ export function createAsset( const p = dirPrefix ? dirPrefix + relativePath : path; const sourcePath = sourceDir ? sourceDir + relativePath : path; - console.log('generatePublicUrl(p, locationName)', generatePublicUrl(p, locationName)); + console.log( + 'generatePublicUrl(p, locationName)', + generatePublicUrl(p, locationName) + ); return { workspaceId, diff --git a/databox/indexer/src/handlers/phraseanet/indexer.ts b/databox/indexer/src/handlers/phraseanet/indexer.ts index b5089b8b0..719c88ae2 100644 --- a/databox/indexer/src/handlers/phraseanet/indexer.ts +++ b/databox/indexer/src/handlers/phraseanet/indexer.ts @@ -59,15 +59,17 @@ export const phraseanetIndexer: IndexIterator = await client.getMetaStruct(dm.databoxId) ); for (const m of metaStructure) { - logger.debug(`Creating "${m.name}" attribute definition`); + logger.info(`Creating "${m.name}" attribute definition`); const id = m.id.toString(); if (!attrClassIndex[defaultPublicClass]) { + const name = 'Phraseanet Public'; + logger.info(`Creating "${name}" attribute class`); attrClassIndex[defaultPublicClass] = await databoxClient.createAttributeClass( defaultPublicClass, { - name: 'Phraseanet Public', + name, public: true, editable: true, workspace: `/workspaces/${workspaceId}`, @@ -104,7 +106,7 @@ export const phraseanetIndexer: IndexIterator = const subDefs = await client.getSubDefinitions(dm.databoxId); for (const sd of subDefs) { if (!classIndex[sd.class]) { - logger.debug(`Creating rendition class "${sd.class}" `); + logger.info(`Creating rendition class "${sd.class}" `); classIndex[sd.class] = await databoxClient.createRenditionClass({ name: sd.class, @@ -117,6 +119,7 @@ export const phraseanetIndexer: IndexIterator = ); await databoxClient.createRenditionDefinition({ name: sd.name, + key: `${sd.name}_${sd.type ?? ''}`, class: `/rendition-classes/${classIndex[sd.class]}`, useAsOriginal: sd.name === 'document', useAsPreview: sd.name === 'preview', diff --git a/databox/indexer/src/handlers/phraseanet/shared.ts b/databox/indexer/src/handlers/phraseanet/shared.ts index 18e3a7124..e0800de01 100644 --- a/databox/indexer/src/handlers/phraseanet/shared.ts +++ b/databox/indexer/src/handlers/phraseanet/shared.ts @@ -7,7 +7,7 @@ import { RenditionInput, } from '../../databox/types'; -const renditionDefinitionMapping = { +const renditionDefinitionMapping: Record = { document: 'original', }; const renditionDefinitionBlacklist = ['original']; @@ -82,6 +82,6 @@ export function createAsset( }; } -export const attributeTypesEquivalence = { +export const attributeTypesEquivalence: Record = { string: 'text', }; diff --git a/databox/indexer/src/lib/axios.ts b/databox/indexer/src/lib/axios.ts index 23e23855c..b84be7fcb 100644 --- a/databox/indexer/src/lib/axios.ts +++ b/databox/indexer/src/lib/axios.ts @@ -87,8 +87,13 @@ export function createHttpClient({ }; } + logger.debug( + `Error response headers (${error.config?.url}): ` + + JSON.stringify(error.response.headers, undefined, 2) + ); logger.error( - `Error response (${error.config.url}): ` + JSON.stringify(filtered, undefined, 2) + `Error response (${error.config?.url}): ` + + JSON.stringify(filtered, undefined, 2) ); } @@ -96,14 +101,22 @@ export function createHttpClient({ } ); + const obfuscate = (str: string) => str.replace(/([\w._-]{5})[\w._-]{30,}/g, '$1***'); + client.interceptors.request.use( - (config) => { - logger.debug(`${config.method.toUpperCase()} ${config.url} -${JSON.stringify(config.headers)}${config.data ? `\n${JSON.stringify(config.data)}` : ''}`); + config => { + if (!config) { + return config; + } + + logger.debug(`${config.method?.toUpperCase()} ${config.url} +${obfuscate(JSON.stringify(config.headers, null, 2))}${ + config.data ? `\n${obfuscate(JSON.stringify(config.data, null, 2))}` : '' + }`); return config; }, - (error) => { + error => { return Promise.reject(error); } ); diff --git a/databox/indexer/src/lib/logger.ts b/databox/indexer/src/lib/logger.ts index ebbc97bf5..41adeca1c 100644 --- a/databox/indexer/src/lib/logger.ts +++ b/databox/indexer/src/lib/logger.ts @@ -10,11 +10,31 @@ const myFormat = printf(({context, level, message, timestamp}) => { return `${timestamp} ${context}.${level.toUpperCase()}: ${message}`; }); +type LogLevel = "debug" | "warn" | "info" | "error"; + +const loggerConfig: { + level: LogLevel; +} = { + level: 'info' +}; + +const loggers: Logger[] = []; + +export function setLogLevel(level: LogLevel): void { + loggerConfig.level = level; + + loggers.forEach(l => l.level = level); +} + export function createLogger(context: string): Logger { - return winstonCreateLogger({ - level: 'debug', + const l = winstonCreateLogger({ + level: loggerConfig.level, format: combine(timestamp(), myFormat), defaultMeta: {context}, transports: [new transports.Console()], }); + + loggers.push(l); + + return l; } diff --git a/databox/indexer/src/types.ts b/databox/indexer/src/types.ts new file mode 100644 index 000000000..2e15e5133 --- /dev/null +++ b/databox/indexer/src/types.ts @@ -0,0 +1,3 @@ +export type CommandCommonOptions = { + debug: boolean; +} diff --git a/databox/indexer/tsconfig.check.json b/databox/indexer/tsconfig.check.json new file mode 100644 index 000000000..d5db6522d --- /dev/null +++ b/databox/indexer/tsconfig.check.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "allowJs": true, + + "types": [ + "node" + ] + }, + "include": [ + "src/*" + ] +} diff --git a/docker-compose.yml b/docker-compose.yml index e9e966992..ac2e97faf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -83,7 +83,7 @@ services: - KEYCLOAK_REALM_NAME - MATOMO_HOST - DISPLAY_SERVICES_MENU - - DASHBOARD_URL + - DASHBOARD_CLIENT_URL - AUTO_CONNECT_IDP - MATOMO_URL - SENTRY_DSN=${CLIENT_SENTRY_DSN} @@ -121,7 +121,7 @@ services: - RABBITMQ_SSL - REPORT_API_URL - DISPLAY_SERVICES_MENU - - DASHBOARD_URL + - DASHBOARD_CLIENT_URL - NEWRELIC_ENABLED - NEWRELIC_LICENSE_KEY - SENTRY_DSN=${PHP_SENTRY_DSN} @@ -634,6 +634,10 @@ services: - UPLOADER_API_URL - UPLOADER_CLIENT_URL - ZIPPY_URL + - SENTRY_DSN + - SENTRY_ENVIRONMENT + - SENTRY_RELEASE + - CLIENT_ID=${DASHBOARD_CLIENT_ID} labels: - "traefik.http.routers.dashboard.rule=Host(`dashboard.${PHRASEA_DOMAIN}`)" @@ -972,9 +976,11 @@ services: - DATABOX_CLIENT_ID - EXPOSE_CLIENT_ID - UPLOADER_CLIENT_ID + - DASHBOARD_CLIENT_ID - DATABOX_CLIENT_URL - EXPOSE_CLIENT_URL - UPLOADER_CLIENT_URL + - DASHBOARD_CLIENT_URL=${DASHBOARD_CLIENT_URL} - POSTGRES_USER - POSTGRES_PASSWORD - POSTGRES_HOST=db diff --git a/expose/api/src/ConfigurationManager.php b/expose/api/src/ConfigurationManager.php index 261feea78..f044e233f 100644 --- a/expose/api/src/ConfigurationManager.php +++ b/expose/api/src/ConfigurationManager.php @@ -32,7 +32,7 @@ class ConfigurationManager ], 'dashboardBaseUrl' => [ 'overridableInAdmin' => false, - 'name' => 'DASHBOARD_URL', + 'name' => 'DASHBOARD_CLIENT_URL', 'type' => 'string', ], 'mapBoxToken' => [ diff --git a/expose/client/src/component/Root.tsx b/expose/client/src/component/Root.tsx index 9c9aace0f..0036828ce 100644 --- a/expose/client/src/component/Root.tsx +++ b/expose/client/src/component/Root.tsx @@ -1,6 +1,6 @@ import {ModalStack} from '@alchemy/navigation'; import {oauthClient} from '../lib/api-client'; -import {AuthenticationProvider, MatomoUser} from '@alchemy/react-auth'; +import {AuthenticationProvider, MatomoUser, SessionExpireContainer} from '@alchemy/react-auth'; import App from './App.tsx'; import {ToastContainer} from 'react-toastify'; @@ -10,11 +10,10 @@ export default function Root({}: Props) { return ( <> - + + diff --git a/expose/client/src/component/RouteProxy.tsx b/expose/client/src/component/RouteProxy.tsx index 3a208b113..70f743604 100644 --- a/expose/client/src/component/RouteProxy.tsx +++ b/expose/client/src/component/RouteProxy.tsx @@ -1,7 +1,7 @@ import type {RouteProxyProps} from '@alchemy/navigation'; import {useAuth, useKeycloakUrls} from '@alchemy/react-auth'; import config from '../config.ts'; -import {keycloakClient} from "../lib/api-client.ts"; +import {keycloakClient} from '../lib/api-client.ts'; export default function RouteProxy({ component: Component, @@ -16,8 +16,8 @@ export default function RouteProxy({ if (!isPublic && !isAuthenticated()) { document.location.href = getLoginUrl(); - return null + return null; } - return + return ; } diff --git a/expose/client/src/component/security/AuthenticationMethod.tsx b/expose/client/src/component/security/AuthenticationMethod.tsx index 469b5aecd..ac7ad55ec 100644 --- a/expose/client/src/component/security/AuthenticationMethod.tsx +++ b/expose/client/src/component/security/AuthenticationMethod.tsx @@ -13,7 +13,7 @@ export default function AuthenticationMethod({}: Props) { const {getLoginUrl} = useKeycloakUrls({ keycloakClient: keycloakClient, autoConnectIdP: config.autoConnectIdP, - }) + }); const onConnect = React.useCallback(() => { setRedirectPath && setRedirectPath(getCurrentPath()); diff --git a/expose/client/src/config.ts b/expose/client/src/config.ts index a0868c88d..0b3aba42e 100644 --- a/expose/client/src/config.ts +++ b/expose/client/src/config.ts @@ -1,4 +1,4 @@ -import {WindowConfig} from '@alchemy/core' +import {WindowConfig} from '@alchemy/core'; declare global { interface Window { diff --git a/expose/client/src/index.tsx b/expose/client/src/index.tsx index fc7d8b092..105c3f41a 100644 --- a/expose/client/src/index.tsx +++ b/expose/client/src/index.tsx @@ -4,8 +4,8 @@ import ConfigWrapper from './component/ConfigWrapper'; import './i18n/i18n'; import AnalyticsProvider from './component/anaytics/AnalyticsProvider'; import React from 'react'; -import config from "./config"; -import {initSentry} from '@alchemy/core' +import config from './config'; +import {initSentry} from '@alchemy/core'; initSentry(config); diff --git a/lib/js/auth/src/client/OAuthClient.ts b/lib/js/auth/src/client/OAuthClient.ts index f04e66930..0824fa2b9 100644 --- a/lib/js/auth/src/client/OAuthClient.ts +++ b/lib/js/auth/src/client/OAuthClient.ts @@ -172,10 +172,7 @@ export default class OAuthClient { return; } - const index = this.listeners[event].findIndex(({h}) => h === callback); - if (index >= 0) { - delete this.listeners[event][index]; - } + this.listeners[event] = this.listeners[event]!.filter(({h}) => h !== callback); } public async getTokenFromAuthCode(code: string, redirectUri: string): Promise { @@ -329,7 +326,11 @@ export default class OAuthClient { const t = this.storage.getItem(this.tokenStorageKey); if (t) { - return this.tokensCache = JSON.parse(t) as AuthTokens; + const tokens = JSON.parse(t) as AuthTokens; + + this.handleSessionTimeout(tokens) + + return this.tokensCache = tokens; } } diff --git a/lib/js/liform-react/.gitignore b/lib/js/liform-react/.gitignore new file mode 100644 index 000000000..62b82089f --- /dev/null +++ b/lib/js/liform-react/.gitignore @@ -0,0 +1,12 @@ +node_modules +npm-debug.log +dist +lib +es +.DS_Store +yarn.lock +.nyc_output/ +coverage/ +examples/bundle* +package-lock.json +built_docs/ diff --git a/lib/js/liform-react/package.json b/lib/js/liform-react/package.json new file mode 100644 index 000000000..2fe35d4bd --- /dev/null +++ b/lib/js/liform-react/package.json @@ -0,0 +1,37 @@ +{ + "name": "@alchemy/liform-react", + "version": "1.0.0", + "description": "Generate forms from json-schema to use with React (and redux-form)", + "main": "./src/index.jsx", + "scripts": {}, + "keywords": [ + "react", + "json-schema", + "form", + "redux-form" + ], + "repository": { + "type": "git", + "url": "https://github.com/limenius/liform-react.git" + }, + "author": "Nacho Martin", + "license": "MIT", + "peerDependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "dependencies": { + "ajv": "^8.12.0", + "classnames": "^2.2.5", + "deepmerge": "^2.0.1", + "lodash": "^4.17.21", + "prop-types": "^15.5.10", + "react-redux": "^9.0.4", + "redux": "^4.2.1", + "redux-form": "^8.3.10" + }, + "devDependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + } +} diff --git a/lib/js/liform-react/src/Form.jsx b/lib/js/liform-react/src/Form.jsx new file mode 100644 index 000000000..e69de29bb diff --git a/lib/js/liform-react/src/buildSyncValidation.js b/lib/js/liform-react/src/buildSyncValidation.js new file mode 100644 index 000000000..187d3f6d0 --- /dev/null +++ b/lib/js/liform-react/src/buildSyncValidation.js @@ -0,0 +1,84 @@ +import Ajv from "ajv"; +import merge from "deepmerge"; +import { set as _set } from "lodash"; + +const setError = (error, schema) => { + // convert property accessor (.xxx[].xxx) notation to jsonPointers notation + if (error.instancePath.charAt(0) === ".") { + error.instancePath = error.instancePath.replace(/[.[]/gi, "/"); + error.instancePath = error.instancePath.replace(/[\]]/gi, ""); + } + const instancePathParts = error.instancePath.split("/").slice(1); + let instancePath = error.instancePath.slice(1).replace(/\//g, "."); + const type = findTypeInSchema(schema, instancePathParts); + + let errorToSet; + if (type === "array" || type === "allOf" || type === "oneOf") { + errorToSet = { _error: error.message }; + } else { + errorToSet = error.message; + } + + let errors = {}; + _set(errors, instancePath, errorToSet); + return errors; +}; + +const findTypeInSchema = (schema, instancePath) => { + if (!schema) { + return; + } else if (instancePath.length === 0 && schema.hasOwnProperty("type")) { + return schema.type; + } else { + if (schema.type === "array") { + return findTypeInSchema(schema.items, instancePath.slice(1)); + } else if (schema.hasOwnProperty("allOf")) { + if (instancePath.length === 0) return "allOf"; + schema = { ...schema, ...merge.all(schema.allOf) }; + delete schema.allOf; + return findTypeInSchema(schema, instancePath); + } else if (schema.hasOwnProperty("oneOf")) { + if (instancePath.length === 0) return "oneOf"; + schema.oneOf.forEach(item => { + let type = findTypeInSchema(item, instancePath); + if (type) { + return type; + } + }); + } else { + return findTypeInSchema( + schema.properties[instancePath[0]], + instancePath.slice(1) + ); + } + } +}; + +const buildSyncValidation = (schema, ajvParam = null) => { + let ajv = ajvParam; + if (ajv === null) { + ajv = new Ajv({ + allErrors: true, + strict: false + }); + } + return values => { + const valid = ajv.validate(schema, values); + if (valid) { + return {}; + } + const ajvErrors = ajv.errors; + + let errors = ajvErrors.map(error => { + return setError(error, schema); + }); + // We need at least two elements + errors.push({}); + errors.push({}); + return merge.all(errors); + }; +}; + +export default buildSyncValidation; + +export { setError }; diff --git a/lib/js/liform-react/src/compileSchema.js b/lib/js/liform-react/src/compileSchema.js new file mode 100644 index 000000000..89becd4d1 --- /dev/null +++ b/lib/js/liform-react/src/compileSchema.js @@ -0,0 +1,44 @@ +function isObject(thing) { + return typeof thing === "object" && thing !== null && !Array.isArray(thing); +} + +function compileSchema(schema, root) { + if (!root) { + root = schema; + } + let newSchema; + + if (isObject(schema)) { + newSchema = {}; + for (let i in schema) { + if (schema.hasOwnProperty(i)) { + if (i === "$ref") { + newSchema = compileSchema(resolveRef(schema[i], root), root); + } else { + newSchema[i] = compileSchema(schema[i], root); + } + } + } + return newSchema; + } + + if (Array.isArray(schema)) { + newSchema = []; + for (let i = 0; i < schema.length; i += 1) { + newSchema[i] = compileSchema(schema[i], root); + } + return newSchema; + } + + return schema; +} + +function resolveRef(uri, schema) { + uri = uri.replace("#/", ""); + const tokens = uri.split("/"); + const tip = tokens.reduce((obj, token) => obj[token], schema); + + return tip; +} + +export default compileSchema; diff --git a/lib/js/liform-react/src/index.jsx b/lib/js/liform-react/src/index.jsx new file mode 100644 index 000000000..c05d4a7bd --- /dev/null +++ b/lib/js/liform-react/src/index.jsx @@ -0,0 +1,68 @@ +import React from "react"; +import PropTypes from "prop-types"; +import DefaultTheme from "./themes/bootstrap3"; +import { reduxForm } from "redux-form"; +import renderFields from "./renderFields"; +import renderField from "./renderField"; +import processSubmitErrors from "./processSubmitErrors"; +import buildSyncValidation from "./buildSyncValidation"; +import { setError } from "./buildSyncValidation"; +import compileSchema from "./compileSchema"; + +const BaseForm = props => { + const { schema, handleSubmit, theme, error, submitting, context } = props; + return ( +
+ {renderField(schema, null, theme || DefaultTheme, "", context)} +
{error && {error}}
+ +
+ ); +}; + +const Liform = props => { + const schema = compileSchema(props.schema); + props.schema.showLabel = false; + const schemaWithOptions = compileSchema(props.schema); + const formName = props.formKey || props.schema.title || "form"; + + const FinalForm = reduxForm({ + form: props.formKey || props.schema.title || "form", + validate: props.syncValidation || buildSyncValidation(schema, props.ajv), + initialValues: props.initialValues, + context: { ...props.context, formName } + })(props.baseForm || BaseForm); + + return ( + + ); +}; + +Liform.propTypes = { + schema: PropTypes.object, + onSubmit: PropTypes.func, + initialValues: PropTypes.object, + syncValidation: PropTypes.func, + formKey: PropTypes.string, + baseForm: PropTypes.func, + context: PropTypes.object, + ajv: PropTypes.object +}; + +export default Liform; + +export { + renderFields, + renderField, + processSubmitErrors, + DefaultTheme, + setError, + buildSyncValidation, + compileSchema, +}; diff --git a/lib/js/liform-react/src/processSubmitErrors.js b/lib/js/liform-react/src/processSubmitErrors.js new file mode 100644 index 000000000..74f0ab95d --- /dev/null +++ b/lib/js/liform-react/src/processSubmitErrors.js @@ -0,0 +1,40 @@ +import { SubmissionError } from "redux-form"; +import { isEmpty as _isEmpty } from "lodash"; // added for empty check + +const convertToReduxFormErrors = obj => { + let objectWithoutChildrenAndFalseErrors = {}; + Object.keys(obj).map(name => { + if (name === "children") { + objectWithoutChildrenAndFalseErrors = { + ...objectWithoutChildrenAndFalseErrors, + ...convertToReduxFormErrors(obj[name]) + }; + } else { + if (obj[name].hasOwnProperty("children")) { + // if children, take field from it and set them directly as own field + objectWithoutChildrenAndFalseErrors[name] = convertToReduxFormErrors( + obj[name] + ); + } else { + if ( + obj[name].hasOwnProperty("errors") && + !_isEmpty(obj[name]["errors"]) + ) { + // using lodash for empty error check, dont add them if empty + objectWithoutChildrenAndFalseErrors[name] = obj[name]["errors"]; + } + } + } + return null; + }); + return objectWithoutChildrenAndFalseErrors; +}; + +const processSubmitErrors = errors => { + if (errors.hasOwnProperty("errors")) { + errors = convertToReduxFormErrors(errors.errors); + throw new SubmissionError(errors); + } +}; + +export default processSubmitErrors; diff --git a/lib/js/liform-react/src/renderField.js b/lib/js/liform-react/src/renderField.js new file mode 100644 index 000000000..2e531301d --- /dev/null +++ b/lib/js/liform-react/src/renderField.js @@ -0,0 +1,51 @@ +import React from "react"; +import deepmerge from "deepmerge"; + +const guessWidget = (fieldSchema, theme) => { + if (fieldSchema.widget) { + return fieldSchema.widget; + } else if (fieldSchema.hasOwnProperty("enum")) { + return "choice"; + } else if (fieldSchema.hasOwnProperty("oneOf")) { + return "oneOf"; + } else if (theme[fieldSchema.format]) { + return fieldSchema.format; + } + return fieldSchema.type || "object"; +}; + +const renderField = ( + fieldSchema, + fieldName, + theme, + prefix = "", + context = {}, + required = false +) => { + if (fieldSchema.hasOwnProperty("allOf")) { + fieldSchema = { ...fieldSchema, ...deepmerge.all(fieldSchema.allOf) }; + delete fieldSchema.allOf; + } + + const widget = guessWidget(fieldSchema, theme); + + if (!theme[widget]) { + throw new Error("liform: " + widget + " is not defined in the theme"); + } + + const newFieldName = prefix ? prefix + fieldName : fieldName; + + return React.createElement(theme[widget], { + key: fieldName, + fieldName: widget === "oneOf" ? fieldName : newFieldName, + label: + fieldSchema.showLabel === false ? "" : fieldSchema.title || fieldName, + required: required, + schema: fieldSchema, + theme, + context, + prefix + }); +}; + +export default renderField; diff --git a/lib/js/liform-react/src/renderFields.js b/lib/js/liform-react/src/renderFields.js new file mode 100644 index 000000000..ee25342fb --- /dev/null +++ b/lib/js/liform-react/src/renderFields.js @@ -0,0 +1,38 @@ +import renderField from "./renderField"; + +export const isRequired = (schema, fieldName) => { + if (!schema.required) { + return false; + } + return schema.required.indexOf(fieldName) !== -1; +}; + +const renderFields = (schema, theme, prefix = null, context = {}) => { + let props = []; + for (let i in schema.properties) { + props.push({ prop: i, propertyOrder: schema.properties[i].propertyOrder }); + } + props = props.sort((a, b) => { + if (a.propertyOrder > b.propertyOrder) { + return 1; + } else if (a.propertyOrder < b.propertyOrder) { + return -1; + } else { + return 0; + } + }); + return props.map(item => { + const name = item.prop; + const field = schema.properties[name]; + return renderField( + field, + name, + theme, + prefix, + context, + isRequired(schema, name) + ); + }); +}; + +export default renderFields; diff --git a/lib/js/liform-react/src/themes/bootstrap3/ArrayWidget.jsx b/lib/js/liform-react/src/themes/bootstrap3/ArrayWidget.jsx new file mode 100644 index 000000000..6e2afd87a --- /dev/null +++ b/lib/js/liform-react/src/themes/bootstrap3/ArrayWidget.jsx @@ -0,0 +1,152 @@ +import React from "react"; +import PropTypes from "prop-types"; +import renderField from "../../renderField"; +import { FieldArray } from "redux-form"; +import { times as _times} from "lodash"; +import ChoiceWidget from "./ChoiceWidget"; +import classNames from "classnames"; + +const renderArrayFields = ( + count, + schema, + theme, + fieldName, + remove, + context, + swap +) => { + const prefix = fieldName + "."; + if (count) { + return _times(count, idx => { + return ( +
+
+ {idx !== count - 1 && count > 1 ? ( + + ) : ( + "" + )} + {idx !== 0 && count > 1 ? ( + + ) : ( + "" + )} + + +
+ {renderField( + { ...schema, showLabel: false }, + idx.toString(), + theme, + prefix, + context + )} +
+ ); + }); + } else { + return null; + } +}; + +const renderInput = field => { + const className = classNames([ + "arrayType", + { "has-error": field.meta.submitFailed && field.meta.error } + ]); + + return ( +
+ {field.label} + {field.meta.submitFailed && + field.meta.error && ( + {field.meta.error} + )} + {renderArrayFields( + field.fields.length, + field.schema.items, + field.theme, + field.fieldName, + idx => field.fields.remove(idx), + field.context, + (a, b) => { + field.fields.swap(a, b); + } + )} + +
+
+ ); +}; + +const CollectionWidget = props => { + return ( + + ); +}; + +const ArrayWidget = props => { + // Arrays are tricky because they can be multiselects or collections + if ( + props.schema.items.hasOwnProperty("enum") && + props.schema.hasOwnProperty("uniqueItems") && + props.schema.uniqueItems + ) { + return ChoiceWidget({ + ...props, + schema: props.schema.items, + multiple: true + }); + } else { + return CollectionWidget(props); + } +}; + +ArrayWidget.propTypes = { + schema: PropTypes.object.isRequired, + fieldName: PropTypes.string, + label: PropTypes.string, + theme: PropTypes.object, + context: PropTypes.object +}; + +export default ArrayWidget; diff --git a/lib/js/liform-react/src/themes/bootstrap3/BaseInputWidget.jsx b/lib/js/liform-react/src/themes/bootstrap3/BaseInputWidget.jsx new file mode 100644 index 000000000..fd7861409 --- /dev/null +++ b/lib/js/liform-react/src/themes/bootstrap3/BaseInputWidget.jsx @@ -0,0 +1,59 @@ +import React from "react"; +import PropTypes from "prop-types"; +import classNames from "classnames"; +import { Field } from "redux-form"; + +const renderInput = field => { + const className = classNames([ + "form-group", + { "has-error": field.meta.touched && field.meta.error } + ]); + return ( +
+ + + {field.meta.touched && + field.meta.error && ( + {field.meta.error} + )} + {field.description && ( + {field.description} + )} +
+ ); +}; + +const BaseInputWidget = props => { + return ( + + ); +}; + +BaseInputWidget.propTypes = { + schema: PropTypes.object.isRequired, + type: PropTypes.string.isRequired, + required: PropTypes.bool, + fieldName: PropTypes.string, + label: PropTypes.string, + normalizer: PropTypes.func +}; + +export default BaseInputWidget; diff --git a/lib/js/liform-react/src/themes/bootstrap3/CheckboxWidget.jsx b/lib/js/liform-react/src/themes/bootstrap3/CheckboxWidget.jsx new file mode 100644 index 000000000..aadcf10cc --- /dev/null +++ b/lib/js/liform-react/src/themes/bootstrap3/CheckboxWidget.jsx @@ -0,0 +1,56 @@ +import React from "react"; +import PropTypes from "prop-types"; +import classNames from "classnames"; +import { Field } from "redux-form"; + +const renderInput = field => { + const className = classNames([ + "form-group", + { "has-error": field.meta.touched && field.meta.error } + ]); + return ( +
+
+ +
+ {field.meta.touched && + field.meta.error && ( + {field.meta.error} + )} + {field.description && ( + {field.description} + )} +
+ ); +}; + +const CheckboxWidget = props => { + return ( + + ); +}; + +CheckboxWidget.propTypes = { + schema: PropTypes.object.isRequired, + fieldName: PropTypes.string, + label: PropTypes.string, + theme: PropTypes.object +}; + +export default CheckboxWidget; diff --git a/lib/js/liform-react/src/themes/bootstrap3/ChoiceExpandedWidget.jsx b/lib/js/liform-react/src/themes/bootstrap3/ChoiceExpandedWidget.jsx new file mode 100644 index 000000000..71b776036 --- /dev/null +++ b/lib/js/liform-react/src/themes/bootstrap3/ChoiceExpandedWidget.jsx @@ -0,0 +1,67 @@ +import React from "react"; +import classNames from "classnames"; +import { Field } from "redux-form"; + +const zipObject = (props, values) => + props.reduce( + (prev, prop, i) => Object.assign(prev, { [prop]: values[i] }), + {} + ); + +const renderChoice = field => { + const className = classNames([ + "form-group", + { "has-error": field.meta.touched && field.meta.error } + ]); + const options = field.schema.enum; + const optionNames = field.schema.enum_titles || options; + + const selectOptions = zipObject(options, optionNames); + return ( +
+ + {Object.entries(selectOptions).map(([value, name]) => ( +
+ +
+ ))} + + {field.meta.touched && + field.meta.error && ( + {field.meta.error} + )} + {field.description && ( + {field.description} + )} +
+ ); +}; + +const ChoiceExpandedWidget = props => { + return ( + + ); +}; + +export default ChoiceExpandedWidget; diff --git a/lib/js/liform-react/src/themes/bootstrap3/ChoiceMultipleExpandedWidget.jsx b/lib/js/liform-react/src/themes/bootstrap3/ChoiceMultipleExpandedWidget.jsx new file mode 100644 index 000000000..f4099150e --- /dev/null +++ b/lib/js/liform-react/src/themes/bootstrap3/ChoiceMultipleExpandedWidget.jsx @@ -0,0 +1,84 @@ +import React from "react"; +import classNames from "classnames"; +import { Field } from "redux-form"; + +const zipObject = (props, values) => + props.reduce( + (prev, prop, i) => Object.assign(prev, { [prop]: values[i] }), + {} + ); + +const changeValue = (checked, item, onChange, currentValue = []) => { + if (checked) { + if (currentValue.indexOf(checked) === -1) { + return onChange([...currentValue, item]); + } + } else { + return onChange(currentValue.filter(items => it === item)); + } + return onChange(currentValue); +}; + +const renderChoice = field => { + const className = classNames([ + "form-group", + { "has-error": field.meta.touched && field.meta.error } + ]); + const options = field.schema.items.enum; + const optionNames = field.schema.items.enum_titles || options; + + const selectOptions = zipObject(options, optionNames); + return ( +
+ + {Object.entries(selectOptions).map(([value, name]) => ( +
+ +
+ ))} + + {field.meta.touched && + field.meta.error && ( + {field.meta.error} + )} + {field.description && ( + {field.description} + )} +
+ ); +}; + +const ChoiceMultipleExpandedWidget = props => { + return ( + + ); +}; + +export default ChoiceMultipleExpandedWidget; diff --git a/lib/js/liform-react/src/themes/bootstrap3/ChoiceWidget.jsx b/lib/js/liform-react/src/themes/bootstrap3/ChoiceWidget.jsx new file mode 100644 index 000000000..783c94f08 --- /dev/null +++ b/lib/js/liform-react/src/themes/bootstrap3/ChoiceWidget.jsx @@ -0,0 +1,78 @@ +import React from "react"; +import PropTypes from "prop-types"; +import classNames from "classnames"; +import { Field } from "redux-form"; +import { zipObject as _zipObject, map as _map } from "lodash"; + +const renderSelect = field => { + const className = classNames([ + "form-group", + { "has-error": field.meta.touched && field.meta.error } + ]); + const options = field.schema.enum; + const optionNames = field.schema.enum_titles || options; + + const selectOptions = _zipObject(options, optionNames); + return ( +
+ + + + {field.meta.touched && + field.meta.error && ( + {field.meta.error} + )} + {field.description && ( + {field.description} + )} +
+ ); +}; + +const ChoiceWidget = props => { + return ( + + ); +}; + +ChoiceWidget.propTypes = { + schema: PropTypes.object.isRequired, + fieldName: PropTypes.string, + label: PropTypes.string, + theme: PropTypes.object, + multiple: PropTypes.bool, + required: PropTypes.bool +}; + +export default ChoiceWidget; diff --git a/lib/js/liform-react/src/themes/bootstrap3/ColorWidget.jsx b/lib/js/liform-react/src/themes/bootstrap3/ColorWidget.jsx new file mode 100644 index 000000000..8a3b5ea71 --- /dev/null +++ b/lib/js/liform-react/src/themes/bootstrap3/ColorWidget.jsx @@ -0,0 +1,17 @@ +import React from "react"; +import PropTypes from "prop-types"; +import BaseInputWidget from "./BaseInputWidget"; + +const ColorWidget = props => { + return ; +}; + +BaseInputWidget.propTypes = { + schema: PropTypes.object.isRequired, + type: PropTypes.string.isRequired, + required: PropTypes.bool, + fieldName: PropTypes.string, + label: PropTypes.string +}; + +export default ColorWidget; diff --git a/lib/js/liform-react/src/themes/bootstrap3/CompatibleDateTimeWidget.jsx b/lib/js/liform-react/src/themes/bootstrap3/CompatibleDateTimeWidget.jsx new file mode 100644 index 000000000..8fb85cf33 --- /dev/null +++ b/lib/js/liform-react/src/themes/bootstrap3/CompatibleDateTimeWidget.jsx @@ -0,0 +1,192 @@ +import React from "react"; +import classNames from "classnames"; +import { Field } from "redux-form"; +import DateSelector from "./DateSelector"; + +// produces an array [start..end-1] +const range = (start, end) => + Array.from({ length: end - start }, (v, k) => k + start); + +// produces an array [start..end-1] padded with zeros, (two digits) +const rangeZeroPad = (start, end) => + Array.from({ length: end - start }, (v, k) => ("0" + (k + start)).slice(-2)); + +const extractYear = value => { + return extractDateTimeToken(value, 0); +}; +const extractMonth = value => { + return extractDateTimeToken(value, 1); +}; +const extractDay = value => { + return extractDateTimeToken(value, 2); +}; +const extractHour = value => { + return extractDateTimeToken(value, 3); +}; +const extractMinute = value => { + return extractDateTimeToken(value, 4); +}; +const extractSecond = value => { + return extractDateTimeToken(value, 5); +}; + +const extractDateTimeToken = (value, index) => { + if (!value) { + return ""; + } + // Remove timezone Z + value = value.substring(0, value.length - 1); + const tokens = value.split(/[-T:]/); + if (tokens.length !== 6) { + return ""; + } + return tokens[index]; +}; + +class CompatibleDateTime extends React.Component { + constructor(props, context) { + super(props, context); + this.state = { + year: null, + month: null, + day: null, + hour: null, + minute: null, + second: null + }; + this.onBlur = this.onBlur.bind(this); + } + + // Produces a RFC 3339 full-date from the state + buildRfc3339Date() { + const year = this.state.year || ""; + const month = this.state.month || ""; + const day = this.state.day || ""; + return year + "-" + month + "-" + day; + } + + // Produces a RFC 3339 datetime from the state + buildRfc3339DateTime() { + const date = this.buildRfc3339Date(); + const hour = this.state.hour || ""; + const minute = this.state.minute || ""; + const second = this.state.second || ""; + return date + "T" + hour + ":" + minute + ":" + second + "Z"; + } + + onChangeField(field, e) { + const value = e.target.value; + let changeset = {}; + changeset[field] = value; + this.setState(changeset, () => { + this.props.input.onChange(this.buildRfc3339DateTime()); + }); + } + onBlur() { + this.props.input.onBlur(this.buildRfc3339DateTime()); + } + render() { + const field = this.props; + const className = classNames([ + "form-group", + { "has-error": field.meta.touched && field.meta.error } + ]); + return ( +
+ +
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+ {field.meta.touched && + field.meta.error && ( + {field.meta.error} + )} + {field.description && ( + {field.description} + )} +
+ ); + } +} +const CompatibleDateTimeWidget = props => { + return ( + + ); +}; + +export default CompatibleDateTimeWidget; + +// Only for testing purposes +export { extractDateTimeToken }; diff --git a/lib/js/liform-react/src/themes/bootstrap3/CompatibleDateWidget.jsx b/lib/js/liform-react/src/themes/bootstrap3/CompatibleDateWidget.jsx new file mode 100644 index 000000000..ef329c0ac --- /dev/null +++ b/lib/js/liform-react/src/themes/bootstrap3/CompatibleDateWidget.jsx @@ -0,0 +1,144 @@ +import React from "react"; +import classNames from "classnames"; +import { Field } from "redux-form"; +import DateSelector from "./DateSelector"; + +// produces an array [start..end-1] +const range = (start, end) => + Array.from({ length: end - start }, (v, k) => k + start); + +// produces an array [start..end-1] padded with zeros, (two digits) +const rangeZeroPad = (start, end) => + Array.from({ length: end - start }, (v, k) => ("0" + (k + start)).slice(-2)); + +const extractYear = value => { + return extractDateToken(value, 0); +}; +const extractMonth = value => { + return extractDateToken(value, 1); +}; +const extractDay = value => { + return extractDateToken(value, 2); +}; + +const extractDateToken = (value, index) => { + if (!value) { + return ""; + } + const tokens = value.split(/-/); + if (tokens.length !== 3) { + return ""; + } + return tokens[index]; +}; + +class CompatibleDate extends React.Component { + constructor(props, context) { + super(props, context); + this.state = { + year: null, + month: null, + day: null, + hour: null, + minute: null, + second: null + }; + this.onBlur = this.onBlur.bind(this); + } + + // Produces a RFC 3339 full-date from the state + buildRfc3339Date() { + const year = this.state.year || ""; + const month = this.state.month || ""; + const day = this.state.day || ""; + return year + "-" + month + "-" + day; + } + + onChangeField(field, e) { + const value = e.target.value; + let changeset = {}; + changeset[field] = value; + this.setState(changeset, () => { + this.props.input.onChange(this.buildRfc3339Date()); + }); + } + + onBlur() { + this.props.input.onBlur(this.buildRfc3339Date()); + } + + render() { + const field = this.props; + const className = classNames([ + "form-group", + { "has-error": field.meta.touched && field.meta.error } + ]); + return ( +
+ +
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+ {field.meta.touched && + field.meta.error && ( + {field.meta.error} + )} + {field.description && ( + {field.description} + )} +
+ ); + } +} +const CompatibleDateWidget = props => { + return ( + + ); +}; + +export default CompatibleDateWidget; + +// Only for testing purposes +export { extractDateToken }; diff --git a/lib/js/liform-react/src/themes/bootstrap3/DateSelector.jsx b/lib/js/liform-react/src/themes/bootstrap3/DateSelector.jsx new file mode 100644 index 000000000..4b5227b6b --- /dev/null +++ b/lib/js/liform-react/src/themes/bootstrap3/DateSelector.jsx @@ -0,0 +1,29 @@ +import React from "react"; + +const DateSelector = props => { + return ( + + ); +}; + +export default DateSelector; diff --git a/lib/js/liform-react/src/themes/bootstrap3/DateTimeWidget.jsx b/lib/js/liform-react/src/themes/bootstrap3/DateTimeWidget.jsx new file mode 100644 index 000000000..1833f8809 --- /dev/null +++ b/lib/js/liform-react/src/themes/bootstrap3/DateTimeWidget.jsx @@ -0,0 +1,8 @@ +import React from "react"; +import BaseInputWidget from "./BaseInputWidget"; + +const DateTimeWidget = props => { + return ; +}; + +export default DateTimeWidget; diff --git a/lib/js/liform-react/src/themes/bootstrap3/DateWidget.jsx b/lib/js/liform-react/src/themes/bootstrap3/DateWidget.jsx new file mode 100644 index 000000000..bbc8bc69a --- /dev/null +++ b/lib/js/liform-react/src/themes/bootstrap3/DateWidget.jsx @@ -0,0 +1,8 @@ +import React from "react"; +import BaseInputWidget from "./BaseInputWidget"; + +const DateWidget = props => { + return ; +}; + +export default DateWidget; diff --git a/lib/js/liform-react/src/themes/bootstrap3/EmailWidget.jsx b/lib/js/liform-react/src/themes/bootstrap3/EmailWidget.jsx new file mode 100644 index 000000000..5c3aac95a --- /dev/null +++ b/lib/js/liform-react/src/themes/bootstrap3/EmailWidget.jsx @@ -0,0 +1,18 @@ +import React from "react"; +import PropTypes from "prop-types"; +import BaseInputWidget from "./BaseInputWidget"; + +const EmailWidget = props => { + return ; +}; + +EmailWidget.propTypes = { + schema: PropTypes.object.isRequired, + fieldName: PropTypes.string, + label: PropTypes.string, + theme: PropTypes.object, + multiple: PropTypes.bool, + required: PropTypes.bool +}; + +export default EmailWidget; diff --git a/lib/js/liform-react/src/themes/bootstrap3/FileWidget.jsx b/lib/js/liform-react/src/themes/bootstrap3/FileWidget.jsx new file mode 100644 index 000000000..28a4dd702 --- /dev/null +++ b/lib/js/liform-react/src/themes/bootstrap3/FileWidget.jsx @@ -0,0 +1,62 @@ +import React from "react"; +import { Field } from "redux-form"; +import classNames from "classnames"; + +const processFile = (onChange, e) => { + const files = e.target.files; + return new Promise(() => { + let reader = new FileReader(); + reader.addEventListener( + "load", + () => { + onChange(reader.result); + }, + false + ); + reader.readAsDataURL(files[0]); + }); +}; + +const File = field => { + const className = classNames([ + "form-group", + { "has-error": field.meta.touched && field.meta.error } + ]); + return ( +
+ + + {field.meta.touched && + field.meta.error && ( + {field.meta.error} + )} + {field.description && {field.description}} +
+ ); +}; + +const FileWidget = props => { + return ( + + ); +}; + +export default FileWidget; diff --git a/lib/js/liform-react/src/themes/bootstrap3/MoneyWidget.jsx b/lib/js/liform-react/src/themes/bootstrap3/MoneyWidget.jsx new file mode 100644 index 000000000..722c866f5 --- /dev/null +++ b/lib/js/liform-react/src/themes/bootstrap3/MoneyWidget.jsx @@ -0,0 +1,61 @@ +import React from "react"; +import PropTypes from "prop-types"; +import classNames from "classnames"; +import { Field } from "redux-form"; + +const renderInput = field => { + const className = classNames([ + "form-group", + { "has-error": field.meta.touched && field.meta.error } + ]); + return ( +
+ +
+ € + +
+ {field.meta.touched && + field.meta.error && ( + {field.meta.error} + )} + {field.description && ( + {field.description} + )} +
+ ); +}; + +const MoneyWidget = props => { + return ( + + ); +}; + +MoneyWidget.propTypes = { + schema: PropTypes.object.isRequired, + fieldName: PropTypes.string, + label: PropTypes.string, + theme: PropTypes.object, + multiple: PropTypes.bool, + required: PropTypes.bool +}; + +export default MoneyWidget; diff --git a/lib/js/liform-react/src/themes/bootstrap3/NumberWidget.jsx b/lib/js/liform-react/src/themes/bootstrap3/NumberWidget.jsx new file mode 100644 index 000000000..00e02be95 --- /dev/null +++ b/lib/js/liform-react/src/themes/bootstrap3/NumberWidget.jsx @@ -0,0 +1,8 @@ +import React from "react"; +import BaseInputWidget from "./BaseInputWidget"; + +const NumberWidget = props => { + return ; +}; + +export default NumberWidget; diff --git a/lib/js/liform-react/src/themes/bootstrap3/ObjectWidget.jsx b/lib/js/liform-react/src/themes/bootstrap3/ObjectWidget.jsx new file mode 100644 index 000000000..deacdbac4 --- /dev/null +++ b/lib/js/liform-react/src/themes/bootstrap3/ObjectWidget.jsx @@ -0,0 +1,27 @@ +import React from "react"; +import PropTypes from "prop-types"; +import renderFields from "../../renderFields"; + +const Widget = props => { + return ( +
+ {props.label && {props.label}} + {renderFields( + props.schema, + props.theme, + props.fieldName && props.fieldName + ".", + props.context + )} +
+ ); +}; + +Widget.propTypes = { + schema: PropTypes.object.isRequired, + fieldName: PropTypes.string, + label: PropTypes.string, + theme: PropTypes.object, + context: PropTypes.object +}; + +export default Widget; diff --git a/lib/js/liform-react/src/themes/bootstrap3/PasswordWidget.jsx b/lib/js/liform-react/src/themes/bootstrap3/PasswordWidget.jsx new file mode 100644 index 000000000..456f2793e --- /dev/null +++ b/lib/js/liform-react/src/themes/bootstrap3/PasswordWidget.jsx @@ -0,0 +1,8 @@ +import React from "react"; +import BaseInputWidget from "./BaseInputWidget"; + +const PasswordWidget = props => { + return ; +}; + +export default PasswordWidget; diff --git a/lib/js/liform-react/src/themes/bootstrap3/PercentWidget.jsx b/lib/js/liform-react/src/themes/bootstrap3/PercentWidget.jsx new file mode 100644 index 000000000..89707dd2a --- /dev/null +++ b/lib/js/liform-react/src/themes/bootstrap3/PercentWidget.jsx @@ -0,0 +1,61 @@ +import React from "react"; +import PropTypes from "prop-types"; +import classNames from "classnames"; +import { Field } from "redux-form"; + +const renderInput = field => { + const className = classNames([ + "form-group", + { "has-error": field.meta.touched && field.meta.error } + ]); + return ( +
+ +
+ + % +
+ {field.meta.touched && + field.meta.error && ( + {field.meta.error} + )} + {field.description && ( + {field.description} + )} +
+ ); +}; + +const Widget = props => { + return ( + + ); +}; + +Widget.propTypes = { + schema: PropTypes.object.isRequired, + fieldName: PropTypes.string, + label: PropTypes.string, + theme: PropTypes.object, + multiple: PropTypes.bool, + required: PropTypes.bool +}; + +export default Widget; diff --git a/lib/js/liform-react/src/themes/bootstrap3/SearchWidget.jsx b/lib/js/liform-react/src/themes/bootstrap3/SearchWidget.jsx new file mode 100644 index 000000000..8a93e3efa --- /dev/null +++ b/lib/js/liform-react/src/themes/bootstrap3/SearchWidget.jsx @@ -0,0 +1,8 @@ +import React from "react"; +import BaseInputWidget from "./BaseInputWidget"; + +const SearchWidget = props => { + return ; +}; + +export default SearchWidget; diff --git a/lib/js/liform-react/src/themes/bootstrap3/StringWidget.jsx b/lib/js/liform-react/src/themes/bootstrap3/StringWidget.jsx new file mode 100644 index 000000000..ad84d401c --- /dev/null +++ b/lib/js/liform-react/src/themes/bootstrap3/StringWidget.jsx @@ -0,0 +1,8 @@ +import React from "react"; +import BaseInputWidget from "./BaseInputWidget"; + +const StringWidget = props => { + return ; +}; + +export default StringWidget; diff --git a/lib/js/liform-react/src/themes/bootstrap3/TextareaWidget.jsx b/lib/js/liform-react/src/themes/bootstrap3/TextareaWidget.jsx new file mode 100644 index 000000000..765b35243 --- /dev/null +++ b/lib/js/liform-react/src/themes/bootstrap3/TextareaWidget.jsx @@ -0,0 +1,57 @@ +import React from "react"; +import PropTypes from "prop-types"; +import classNames from "classnames"; +import { Field } from "redux-form"; + +const renderInput = field => { + const className = classNames([ + "form-group", + { "has-error": field.meta.touched && field.meta.error } + ]); + return ( +
+ +