commit ed437d2e31e7e70731e691e9c31b3919f6f8ad5f Author: Valdior Date: Wed May 14 21:42:26 2025 +0200 init commit diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..90913b4 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,30 @@ +# http://editorconfig.org + +# A special property that should be specified at the top of the file outside of +# any sections. Set to true to stop .editor config file search on current file +root = true + +[*] +# Indentation style +# Possible values - tab, space +indent_style = space + +# Indentation size in single-spaced characters +# Possible values - an integer, tab +indent_size = 2 + +# Line ending file format +# Possible values - lf, crlf, cr +end_of_line = lf + +# File character encoding +# Possible values - latin1, utf-8, utf-16be, utf-16le +charset = utf-8 + +# Denotes whether to trim whitespace at the end of lines +# Possible values - true, false +trim_trailing_whitespace = true + +# Denotes whether file should end with a newline +# Possible values - true, false +insert_final_newline = true diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..8572dcc --- /dev/null +++ b/.eslintignore @@ -0,0 +1,6 @@ +dist/** +src/index.html +flow-typed/** +node_modules/** +seed/node_modules/** +admin/node_modules/** diff --git a/.eslintrc.js b/.eslintrc.js new file mode 100644 index 0000000..7f994c2 --- /dev/null +++ b/.eslintrc.js @@ -0,0 +1,13 @@ +module.exports = { + "extends": "./seed/.eslintrc.js", + "settings": { + "import/resolver": { + "webpack":{ + config: require.resolve('./seed/config/webpack/default.js') + }, + "node": { + "paths": ["src", "seed/src", "admin/src"] + }, + } + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eb8a5a2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.idea +node_modules +package-lock.json +dist +.DS_store diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 0000000..74cf995 --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,7 @@ +module.exports = { + singleQuote: true, + trailingComma: 'all', + printWidth: 100, + tabWidth: 2, + useTabs: false, +}; diff --git a/README.md b/README.md new file mode 100644 index 0000000..3a2f4d3 --- /dev/null +++ b/README.md @@ -0,0 +1,121 @@ +# Project + +Creobis admin + +# Author + +Nicolas Bernier + +# Git modules + +## Seed react + +Add this git as a submodule + +git@gitlab.com:makeit-group/apps/seed-react.git + +(you must have the right access) + +Add it as seed/ (the name/path => very important!) + +## Seed admin + +Add this git as a submodule + +git@gitlab.com:makeit-group/apps/seed-admin.git + +(you must have the right access) + +Add it as admin/ (the name/path => very important!) + +## Strucutre + +the final structure of a project containing this seed shoudl be + +-- project-name/ +---- admin (seed-admin) +---- seed (seed-react) + +# Installation + +(after gitmodules) + +npm i +cd seed +npm i +cd ../admin +npm i + +or directly + +npm i && cd seed && npm i && cd ../admin && npm i && cd ../seed + +# Structure Project + +/components : react part + +- /enhancers : high order components +- /formItems : components used by redux-form +- /forms : redux-from forms +- /global : global components of the app (Navigation, Header, ...) +- /items : components used in multiple other components +- /layouts : layout components of the app +- /listItems : components used in list +- /pageItems : specific sub-part of pages (if used several times, shoudl be moved to items) +- /routes : page components (endpoint of the react-router) +- /structure : structural components taking children + +/store : redux part + +- /actions +- /apis +- /reducers +- /sagas +- /utils + +/styles : general scss + +/types : flow types + +# Generation + +Make sure to have plop installed globally + +sudo npm i -g plop + +then simply run the following command and follow the terminal instructions + +plop + +# Seed + +The seed containes all the shared logic accross projects + +# Storybook + +cd ./seed +yarn storybook + +it will also look at the parent stories + +# Test + +cd ./seed +yarn test + +it will also look at the parent test + +# Start build + +cd ./seed +yarn start + +yarn build + +# Icons + +Font Awesome is installed in the seed and imported in the admin + +# Zeus + +zeus https://oxaav8d1i7.execute-api.eu-central-1.amazonaws.com/dev/ ./src/config --ts diff --git a/admin/.gitignore b/admin/.gitignore new file mode 100644 index 0000000..75c5ab7 --- /dev/null +++ b/admin/.gitignore @@ -0,0 +1,5 @@ +.idea +node_modules +package-lock.json +dist +.DS_Store diff --git a/admin/README-config.md b/admin/README-config.md new file mode 100644 index 0000000..179db92 --- /dev/null +++ b/admin/README-config.md @@ -0,0 +1,287 @@ +# Models + +The models of the admin have to be in the parent src/config folder + +## root + +The root file must import all the + +- Resource transform function +- Resource options +- Resource model + Othe all the resources + +## Resource transform function + +Function used to display additional informations about the model on their listing/detail view + +```javascript +export const item = obj => ({ + infos: { + info1: obj.something, + ... + info4: obj.somethingElse, + }, +}) +``` + +### Resource options + +The options is an object having the following possible attributes + +```javascript +export const options = { + titleList: 'string', //List title + titleUpdate: 'string', //Edit title + titleCreate: 'string', //Add title + titleDetail: 'string', //Detail title + successCreate: 'string, // Success create message + successUpdate: 'string, // Success update message + filters: { // filter options + ... + }, + exportable: boolean, // show export action on listing view + confirmOnSave: boolean, // show confirm modal when saving + noDelete: boolean, // disabled or not delete + noDetail: boolean, // disabled or not view + noUpdate: boolean, // disabled or not edit + noCreate: boolean, // disabled or not create + // Other config + standalone: boolean, // when listData (= subresource) that is a standalone API => avoid to add the ?parent type and id in the url (since standalone) + separated: boolean, // when subs (= subresource) that has a separatated view (sometime with another submenu) + // legend options + legend: { + titleDetail: 'string', // title of the legend + infos: { + info1: 'string', // Detail about info + info1Detail: 'string', // Detail text for edit/view + // up to info4 + }, + }, + callApiOptions: { + // Add options to detail call api + detail: { + noSuccess: true, + }, + }, + baseNav: // base url for subnav link + nav:[ // subnavigation + { + title: 'title', + link: 'absolute_url', + maxWidth: 50, + minWidth: 50, + }, + // no link => separator + { + title: 'Data', + icon: 'database', + }, + ], + headers: [ // array of headers + { + disableSort: boolean, // disableSort + noOrder: boolean, // disableSort alias + type: 'status' | 'date' | 'small' | '', + key: 'string', // data key in the object, + title: 'string', // title of the column, + list: 'array', // array of result when list + values: 'string', // path into the content for the list, + noUpdate: 'boolean', // disable fast editing on column. + fieldName: 'string', // name of the field object if !== to the key + pathIdKey: 'exemple._id', // path to id key used for update. + link: 'string', // reference of transform funciton key dynamic link + // For link + id: 'string', // data key in the object for the id of the linked resource, + resource: 'string', // resource name (for linked resource) + resourceType: 'detail' | 'edit' | 'listing', // type of target + searchParameters: Object, // additional search parameters for link, + avoidContentText: 'boolean' // Avoid rendering header text with + otherResource: 'string', // choose model who must be based the field in FastUpdate case ( Example: check Infamy Project ) + objectToSend: 'string', // Path to the object who must be send in generic FastUpdate ( to use when otherResource is Used ) + } + ], + // Complementary form + complementary: { + // Message success + title: 'Post comment', + success: 'Success Message', + api: '', + // Other model + fields: complementaryModel, + }, + // Other actions forms models + actions: [ + { + title: 'Action1', + // api path + api: '', + success: 'Success Message', + }, + { + title: 'Action2', + // api path + api: '', + success: 'Success Message', + }, + ], +} +``` + +## Model object + +Object structure describing the object (object of field) + +### fields + +#### structural (view OR edit) + +- Type column + +```javascript +const model={ + ..., + column:{ + type: 'column', + fields: { + // any structure + } + } +} +``` + +- Type section + +```javascript +const model={ + ..., + section:{ + type: 'section', + fields: { + // any structure + } + } +} +``` + +- Type list + +```javascript +const model={ + ..., + list:{ + type: 'list', + label: 'list', + labelAdd: 'add in list', + fields: { + // any structure + }, + } +} +``` + +### form item + +- General structure + +```javascript +const model={ + ..., + elem: { + type: 'string' | 'link' | 'textarea' | 'multi' | 'multiarea' | 'text' | 'date' | 'datetime' | 'picture' | 'select' | 'bool' | 'wysiwyg' | 'schedule', + first: boolean, // disable margin top (for top elements) + required: boolean, // required or not + name: string, // path in the data model + disabled: boolean, // disabled or not + label: string, // label to display, + hideDetail: boolean, // Hide field for detail view + hideUpdate: boolean, // Hide field for update view, + hideCreate: boolean, // Hide field for create view, + } +} +``` + +- link + +Attach link to value + +```javascript +const model={ + ..., + elem: { + type: 'link', + resource: 'string', // resource name (for linked resource) + resourceType: 'detail' | 'edit' | 'listing', // type of target + link: 'string', // path of the value in the data object + id: 'string', // same as link + searchParameters: Object, // additional search parameters + } +``` + +- Text + +```javascript +const model={ + ..., + elem: { + type: 'text', + api: Object, // api parameters to give + modelValue: { value: '\_id', text: 'title.fr' }, + } +} +``` + +- Datetime + +```javascript +const model={ + ..., + elem: { + type: 'datetime', + dateFormat: 'LL', + timeFormat: 'HH:mm' + } +} +``` + +- Select + +Additional data + +```javascript +const model={ + ..., + elem: { + type: 'select', + values: 'string' || Array, // items for the customSelect + list: 'Array', // items for the customSelect + asObject: boolean, // tel customSelect how to select value + apiPayload: Object, // api parameters to give + api: Object, // allias of apiPayload + modelValues: { value: '\_id', text: 'title.fr' }, // values to use in the model to display and save data + } +} +``` + +- Picture + +Additional data + +```javascript +const model={ + ..., + elem: { + ..., + type: 'picture', + s3Upload: 'boolean', // If S3 Upload is used + aspectRatio: 1.25, // aspect ratio for the cropper + displayValue: 'string', // if returned value is an object eg: { thumb: .... , full: ...} choose path to display eg:('full') + saveValue: 'string', // key in the result object to store in the input value, + asObject: // similar to saveValue but says to the element to key the entire response (if not provided try to get res.url || res.default || res.picture || res) + label: 'picture 4', + name: 'pictures.pic4', + apiPath: 'string', // path of the auto upload api in the apis object + url: string, // api for the auto upload (alternative to apiPath) /!\ if s3Upload === true url must be api to get upload link + } +} +``` diff --git a/admin/README-project.md b/admin/README-project.md new file mode 100644 index 0000000..2ae4cbf --- /dev/null +++ b/admin/README-project.md @@ -0,0 +1,79 @@ +# Project + +Seed admin + +# Author + +Nicolas Bernier + +# Infos + +## General + +This project needs to be the submodule of a project. +He is not supposed to be launched on its own. + +## Seed react + +This project is supposed to at the same level than another submodule *seed react* + +git@gitlab.com:makeit-group/apps/seed-react.git + +(you must have the right access) + +## Strucutre + +the final structure of a project containing this seed shoudl be + +-- project-name/ +---- admin (seed-admin) +---- seed (seed-react) + + +# Src structure + +/components : react part + +- /enhancers : high order components +- /formHelpers : components by the forms in +- /formItems : components used by redux-form +- /forms : redux-from forms +- /global : global components of the app (Navigation, Header, ...) +- /items : components used in multiple other components +- /layouts : layout components of the app +- /listItems : components used in list +- /pageItems : specific sub-part of pages (if used several times, shoudl be moved to items) +- /routes : page components (endpoint of the react-router) +- /structure : structural components taking children + +/store : redux part + +- /actions +- /apis +- /reducers +- /sagas +- /utils + +/styles : general scss + +/types : flow types + +# Generation + +Make sure to have plop installed globally + +sudo npm i -g plop + +then simply run the following command and follow the terminal instructions + +plop + +# Icons + +We use this librairie (check in dependencies) + +https://react-icons.netlify.com/#/ + +# Eslint + +Could be removed as it should be present in the parent diff --git a/admin/package.json b/admin/package.json new file mode 100644 index 0000000..32fe28c --- /dev/null +++ b/admin/package.json @@ -0,0 +1,22 @@ +{ + "name": "seed-admin", + "version": "1.0.0", + "author": "Nicolas Bernier", + "license": "MIT", + "private": true, + "scripts": {}, + "dependencies": { + "@ckeditor/ckeditor5-build-classic": "git+https://github.com/Nico924/ckeditor5-build-classic.git", + "@ckeditor/ckeditor5-react": "1.1.1", + "chart.js": "^2.8.0", + "core-js": "^2.6.5", + "dom-helpers": "^5.1.3", + "draft-js": "^0.11.7", + "draftjs-to-html": "^0.8.4", + "html-to-draftjs": "^1.4.0", + "react-countup": "^4.1.3", + "react-draft-wysiwyg": "^1.12.13", + "react-infinite-scroller": "^1.2.4", + "unflatten": "^1.0.4" + } +} diff --git a/admin/plopfile.js b/admin/plopfile.js new file mode 100644 index 0000000..c15af6d --- /dev/null +++ b/admin/plopfile.js @@ -0,0 +1,18 @@ +/* eslint-disable import/no-extraneous-dependencies */ +const path = require('path'); + +const templateDir = '../seed/plop-templates'; + +const getTemplatePath = filename => path.join(templateDir, filename); + +const componentPath = './src/components'; +const storePath = './src/store'; + +const plopBase = require('../seed/plopbase'); + +module.exports = plopBase({ + getTemplatePath, + path, + storePath, + componentPath, +}); diff --git a/admin/src/components/enhancers/CustomFixedTable/CustomFixedTable.stories.tsx b/admin/src/components/enhancers/CustomFixedTable/CustomFixedTable.stories.tsx new file mode 100644 index 0000000..c65f499 --- /dev/null +++ b/admin/src/components/enhancers/CustomFixedTable/CustomFixedTable.stories.tsx @@ -0,0 +1,8 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import React from 'react'; +import { storiesOf } from '@storybook/react'; +import CustomFixedTable from './CustomFixedTable'; + +storiesOf('admin/structure/CustomFixedTable', module).add('CustomFixedTable component', () => ( + +)); diff --git a/admin/src/components/enhancers/CustomFixedTable/CustomFixedTable.test.tsx b/admin/src/components/enhancers/CustomFixedTable/CustomFixedTable.test.tsx new file mode 100644 index 0000000..09337c0 --- /dev/null +++ b/admin/src/components/enhancers/CustomFixedTable/CustomFixedTable.test.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import CustomFixedTable from './CustomFixedTable'; + +describe('CustomFixedTable', () => { + it('should render without crashing', () => { + shallow(); + }); +}); diff --git a/admin/src/components/enhancers/CustomFixedTable/CustomFixedTable.tsx b/admin/src/components/enhancers/CustomFixedTable/CustomFixedTable.tsx new file mode 100644 index 0000000..9df86e3 --- /dev/null +++ b/admin/src/components/enhancers/CustomFixedTable/CustomFixedTable.tsx @@ -0,0 +1,663 @@ +import * as React from 'react'; +import classNames from 'classnames/bind'; +import _ from 'lodash'; +import TextItem from 'components/items/TextItem'; +import ModuleButton from 'components/items/ModuleButton'; +import ReactPaginate from 'react-paginate'; +import { withRouter } from 'react-router-dom'; +import VirtualTable from 'components/pageItems/VirtualTable'; +import Loading from 'components/items/Loading'; +import { RiOrderPlayFill } from 'react-icons/ri'; + +import '!style-loader!css-loader!react-virtualized/styles.css'; + +import qs from 'query-string'; + +import filter from 'lodash/filter'; +import { config } from 'config/general'; +import { clone } from 'store/utils/helper'; +import styleIdentifiers from './customFixedTable.scss'; + +const styles = classNames.bind(styleIdentifiers); + +export interface CustomFixedTableProps { + noCheckbox?: boolean; + items: any[]; + headers: Record[]; + loaded?: boolean; + loading?: boolean; + customOrder?: Function; + itemProps?: Record; + noShadow?: boolean; + location: Record; + history: Record; + url?: string; + loadFunc?: Function; + handleFilters?: Function; + subhead?: React.Element; + actions?: React.Element; + legend?: React.Element; + actionDelete?: Function; + title?: string; + loaded?: boolean; + virtual?: boolean; + loading?: boolean; + loadMore?: Function; + noMore?: boolean; + itemProps?: Record; + // filters + displayFilters?: boolean; + filtersProps?: Record; + filtersInitialValues?: Record; + // reset + pagination?: Record; + currentPage?: number; + noResultMessage?: string; + // language + lg?: string; + // More + inModule?: boolean; + // legend + legend?: object; + transformFunction?: Function; +} + +interface CustomFixedTableState { + list: any[]; + filters: Record; + masterCheck: boolean; + order: string; + asc: boolean; +} + +function CustomFixedTable( + ItemComponent: React.ComponentType, + FiltersForm: React.Component, +) { + class FixedTable extends React.Component { + constructor(props: CustomFixedTableProps) { + super(props); + + const { location } = props; + + this.state = { + // used to check the item internaly (could have used the store as well) + list: [], + filters: qs.parse(location.search), + masterCheck: false, + checkedItems: [], + order: '', + asc: true, + selectedLegends: [], + // checked: [], + }; + } + + componentDidMount() { + this.updateList(); + this.loadData(); + } + + componentDidUpdate(prevProps: CustomFixedTableProps, prevState) { + this.updateList(prevProps); + this.onUpdate(prevProps, prevState); + } + + // Could be done in reducers + onItemClick = (id: number, targetValue?: boolean) => { + const list = this.items(); + + let { checkedItems } = this.state; + + checkedItems = checkedItems || []; + + const index = checkedItems.indexOf(id); + + if (targetValue === true) { + // force true + if (index < 0) checkedItems.push(id); + // else keep + } else if (targetValue === false && index >= 0) { + if (index >= 0) checkedItems.splice(index, 1); + // else don't change anything + } else if (index < 0) { + checkedItems.push(id); + } else { + checkedItems.splice(index, 1); + } + + let masterCheck = false; + if (checkedItems.length === list.length) { + masterCheck = true; + } + + this.setState({ + checkedItems, + masterCheck, + }); + + // const index = _.findIndex(list, e => e[config.idKey] === id); + // const newElement = list[index]; + + // let masterCheck = true; + // // toggle part + // if (targetValue !== undefined) newElement.checked = targetValue; + // else if (newElement.checked === true) { + // newElement.checked = false; + // } else { + // newElement.checked = true; + // } + // list.splice(index, 1, newElement); + + // if (!targetValue) { + // list.forEach(element => { + // if (!element.checked) masterCheck = false; + // }); + // this.setState({ + // list, + // masterCheck, + // }); + // } + }; + + onUpdate = (prevProps?: CustomFixedTableProps, prevState) => { + const { location, handleChecked } = this.props; + const { checkedItems } = this.state; + + // provided checked items to parent + if (checkedItems && handleChecked) { + handleChecked(checkedItems); + } + + if (!prevProps || location !== prevProps.location) { + this.setState( + { + checkedItems: [], + masterCheck: false, + order: '', + asc: true, + filters: qs.parse(location.search), + }, + this.loadData, + ); + } + }; + + // getCheckedItems = () => { + // const { checked } = this.state; + // const list = this.items(); + + // const data = []; + // if (!list || !Array.isArray(list)) return data; + // list.forEach(element => { + // if (element.checked) data.push(element); + // }); + + // if (checked.length !== data.length) this.setState({ checked: data }); + + // return data; + // }; + + // Update location + setFilters = filters => { + const { history, url } = this.props; + + history.push({ + pathname: url, + search: qs.stringify(filters), + }); + }; + + // Update the list displayed internaly + updateList = (prevProps?: Record) => { + const { items, resourceData } = this.props; + + if (!prevProps || prevProps.items !== items) { + this.setState({ + list: items, + }); + this.resetSelectedLegend(); + } + }; + + loadData = (more?: boolean) => { + const { loadFunc } = this.props; + + const { filters } = this.state; + + if (more && loadFunc) { + loadFunc({ + ...filters, + more: true, + }); + } else if (loadFunc) { + loadFunc(filters); + } + }; + + applyFilters = values => { + const { filters } = this.state; + const { handleFilters } = this.props; + + const newFilters = handleFilters ? handleFilters(values, filters) : values; + + if (newFilters) this.setFilters(newFilters); + }; + + loadPage = page => { + const currentPage = parseInt(page.selected, 10) + 1; + + const { filters } = this.state; + + const newFilters = { + ...filters, + page: currentPage, + }; + + this.setFilters(newFilters); + }; + + masterCheck = () => { + const { masterCheck } = this.state; + + const list = this.items(); + + list.forEach(element => { + this.onItemClick(element[config.idKey], !masterCheck); + }); + + this.setState({ + masterCheck: !masterCheck, + }); + }; + + // Get internal list + items = () => { + const { list } = this.state; + return list || []; + }; + + // Get actually displayed data + filtered = () => { + const { order, asc, selectedLegends } = this.state; + const { customOrder, lg, transformFunction } = this.props; + + let filtered = this.items(); + + if (selectedLegends.length > 0) { + filtered = filter(filtered, item => { + const transformed = + typeof transformFunction === 'function' ? transformFunction(item) : item; + let dismiss = false; + for (let i = 0; i < selectedLegends.length; i++) { + if (transformed && transformed.infos && !transformed.infos[selectedLegends[i]]) { + dismiss = true; + } + } + return !dismiss; + }); + } + + const sorted = _.orderBy( + filtered, + (item: Record) => { + if (customOrder) return customOrder(item, order) || false; + const val = _.get(item, order); + /* eslint-disable */ + + if (new Date(val) != 'Invalid Date') return new Date(val).getTime(); + /* eslint-enable */ + if (!isNaN(val)) return parseFloat(val); + if (typeof val === 'string') return val.toUpperCase(); + // case multilingual + if (typeof val === 'object' && typeof val[lg] === 'string') return val[lg].toUpperCase(); + return ''; + }, + [asc ? 'asc' : 'desc'], + ); + + return sorted; + }; + + orderBy = (field?: string) => { + const { order, asc } = this.state; + + if (order !== field) { + this.setState({ + order: field, + asc: true, + }); + } else { + this.setState({ + asc: !asc, + }); + } + }; + + listHeadItem = (item: Record) => { + const { order, asc } = this.state; + if (item.key) { + return ( +
this.orderBy(item.key)} + > + + {order === item.key && ( + + + + )} +
+ ); + } + return ( +
+ +
+ ); + }; + + setSort = options => { + const { sortBy } = options; + this.orderBy(sortBy); + }; + + handleCheckVirtual = res => { + if (res.all) this.masterCheck(); + + if (res.item) this.onItemClick(res.item[config.idKey]); + }; + + /** + * Legend features + */ + + resetSelectedLegend = () => { + this.setState({ + selectedLegends: [], + }); + }; + + selectLegend = key => { + const { selectedLegends } = this.state; + + const newList = selectedLegends.slice(); + const index = newList.indexOf(key); + + if (index >= 0) { + newList.splice(index, 1); + } else { + newList.push(key); + } + + this.setState({ + selectedLegends: newList, + }); + }; + + renderStaticTable = () => { + const { noCheckbox, itemProps, headers } = this.props; + const { masterCheck } = this.state; + const list = this.filtered(); + return ( + +
+
+ + + + {!noCheckbox && ( + + )} + + {headers && + headers.map((item, key) => ( + + ))} + + +
+
+
+ {this.listHeadItem({ + title: '#', + })} + + {this.listHeadItem(item)} + +
+
+
+ + + {list.map((item, index) => ( + this.onItemClick(item.id)} + item={item} + index={index} + key={item.id || index} + noCheckbox={noCheckbox} + {...itemProps} + /> + ))} + +
+
+
+
+ ); + }; + + /** + * Render legend + */ + + renderLegend = () => { + const { legend } = this.props; + + if (!legend || !legend.infos) return ; + const { selectedLegends } = this.state; + const keys = Object.keys(legend.infos); + + return ( +
+ {legend.title && ( +
+ +
+ )} + {keys.map((key, index) => { + const text = legend.infos[key]; + if (key.indexOf('Detail') >= 0) return false; + return ( +
this.selectLegend(key)} + className={styles('item', key, selectedLegends.indexOf(key) >= 0 && 'selected')} + > +
{text}
+
+ ); + })} +
+ ); + }; + + /** + * Table + */ + renderVirtualTable = () => { + const { itemProps, headers, noCheckbox, inModule, options, noModule, ref } = this.props; + const { order, asc, masterCheck, checkedItems } = this.state; + const list = this.filtered(); + + return ( + this.handleCheckVirtual(val)} + masterCheck={masterCheck} + checkedItems={checkedItems} + inModule={inModule} + noModule={noModule} + moduleOptions={options} + {...itemProps} + /> + ); + }; + + render() { + const { + subhead, + actions, + actionDelete, + title, + loaded, + loading, + legend, + loadMore, + noMore, + virtual, + // filters + displayFilters, + filtersProps, + filtersInitialValues, + // reset + pagination, + currentPage, + noResultMessage, + noHeader, + } = this.props; + + const { filters, checkedItems } = this.state; + const list = this.filtered(); + + // const checkedList = this.getCheckedItems(); + + return ( +
+ {!noHeader && ( +
+
+

+ +

+ {subhead &&
{subhead}
} +
+
+ {!checkedItems || checkedItems.length === 0 ? ( + actions + ) : ( + actionDelete && actionDelete(checkedItems)} + /> + )} +
+
+ )} + {displayFilters && ( +
+ +
+ )} +
+ {loaded && list.length === 0 && ( +
+ +
+ )} + {list.length === 0 && loading && ( +
+
+ +
+
+ )} + {list.length > 0 && !virtual && this.renderStaticTable()} + {list.length > 0 && virtual && this.renderVirtualTable()} +
+
+
+
+ + {(list && list.length) || '...'} +
+ {this.renderLegend()} +
+
+ {list.length > 0 && !noMore && ( +
+ !loading && this.loadData(true)} + /> +
+ )} + {pagination && pagination.last_page > 1 && ( + + )} +
+
+
+ ); + } + } + + return withRouter(FixedTable); +} + +export default CustomFixedTable; diff --git a/admin/src/components/enhancers/CustomFixedTable/customFixedTable.scss b/admin/src/components/enhancers/CustomFixedTable/customFixedTable.scss new file mode 100644 index 0000000..ccaa366 --- /dev/null +++ b/admin/src/components/enhancers/CustomFixedTable/customFixedTable.scss @@ -0,0 +1,206 @@ +@import '~styles/mixins'; +@import '~styles/adminMixins'; +@import '~styles/items/checkedBox'; + +.CustomFixedTable { + position: relative; + height: 100%; + padding-top: $height_page_header; + padding-bottom: $height_page_footer; + + &.no-header { + padding-top: 0px; + } + + .header { + @include fixedHeader; + } + + .footer { + @include fixedFooter; + } + + &.filters { + padding-top: $height_filters; + } + + .filters { + @include fixedStructure; + // background-color: $color_dark; + height: $height_filters; + border-bottom: 1px solid $color_border; + top: 0; + } + + $height_table_header: 42px; + + .listing { + height: 100%; + width: 100%; + overflow: auto; + } + + .table-wrapper { + height: 100%; + position: relative; + padding-top: $height_table_header; + min-width: $table_min_width; + + .table-header { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: $height_table_header; + border-bottom: 1px solid $color_border; + box-shadow: 0 2px 20px 0 rgba(0, 0, 0, 0.05); + + th { + height: $height_table_header; + } + } + + .table-content { + height: 100%; + overflow: auto; + } + + .box-wrapper { + @include checkedBox; + } + + table { + border-collapse: collapse; + table-layout: fixed; + width: 100%; + } + + .headItem { + padding: $table_head_spacing; + + &.empty { + padding: 0; + } + + &.center { + text-align: center; + } + } + + .sortable-head { + width: 100%; + cursor: pointer; + padding-right: 30px; + position: relative; + + .arrow { + @include v_align; + right: 8px; + + &.inverted { + transform: translateY(-50%) rotate(180deg); + } + } + } + + th { + font-size: 12px; + color: $color-grey-font; + text-transform: uppercase; + vertical-align: middle; + //opacity: 0.6; + text-align: left; + user-select: none; + @include cellStyle; + } + + td, + th { + vertical-align: middle; + } + + tbody { + tr { + border-top: 1px solid $color_border; + + &:first-child { + border-top: none; + } + } + } + } + + .no-result, + .loading { + padding: 20px; + //background-color:$color-grey-background; + text-align: center; + + .loading-item { + width: 40px; + display: inline-block; + } + } + + .pages { + font-size: 12px; + margin-top: 15px; + text-align: right; + + li { + @include middle; + list-style: none; + } + + a { + outline: none; + } + + .page { + a { + border: 1px solid $color_border; + @include ball(25px); + background-color: white; + border-radius: $border_radius; + + &:hover { + cursor: pointer; + background-color: $color-grey-background; + } + } + + &.active { + font-weight: bold; + } + + margin: 0 4px; + } + + .disabled { + a { + cursor: default; + } + + opacity: 0.5; + } + } + + .legend { + @include legend; + + .item { + position: relative; + cursor: pointer; + + &:hover:before, + &.selected:before { + content: ''; + @include absolute-fill; + background-color: rgba(255, 255, 255, 0.15); + } + &.selected { + box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.25); + } + } + } +} diff --git a/admin/src/components/enhancers/CustomFixedTable/index.ts b/admin/src/components/enhancers/CustomFixedTable/index.ts new file mode 100644 index 0000000..57378e7 --- /dev/null +++ b/admin/src/components/enhancers/CustomFixedTable/index.ts @@ -0,0 +1,3 @@ +import CustomFixedTable from './CustomFixedTable'; + +export default CustomFixedTable; diff --git a/admin/src/components/enhancers/ModuleDetailStructure/ModuleDetailStructure.stories.tsx b/admin/src/components/enhancers/ModuleDetailStructure/ModuleDetailStructure.stories.tsx new file mode 100644 index 0000000..7f54fa9 --- /dev/null +++ b/admin/src/components/enhancers/ModuleDetailStructure/ModuleDetailStructure.stories.tsx @@ -0,0 +1,7 @@ +import React from 'react'; +import { storiesOf } from '@storybook/react'; +import { text, object, color, dom, array } from '@storybook/addon-knobs'; + +import ModuleDetailStructure from './ModuleDetailStructure'; + +storiesOf('newComponents/ModuleDetailStructure', module).add('Default', () => ); diff --git a/admin/src/components/enhancers/ModuleDetailStructure/ModuleDetailStructure.test.tsx b/admin/src/components/enhancers/ModuleDetailStructure/ModuleDetailStructure.test.tsx new file mode 100644 index 0000000..742e66a --- /dev/null +++ b/admin/src/components/enhancers/ModuleDetailStructure/ModuleDetailStructure.test.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import ModuleDetailStructure from './ModuleDetailStructure'; + +describe('ModuleDetailStructure', () => { + it('should render without crashing', () => { + shallow(); + }); +}); diff --git a/admin/src/components/enhancers/ModuleDetailStructure/ModuleDetailStructure.tsx b/admin/src/components/enhancers/ModuleDetailStructure/ModuleDetailStructure.tsx new file mode 100644 index 0000000..5379cd4 --- /dev/null +++ b/admin/src/components/enhancers/ModuleDetailStructure/ModuleDetailStructure.tsx @@ -0,0 +1,97 @@ +import React, { Component } from 'react'; +import classNames from 'classnames/bind'; +import TextItem from 'components/items/TextItem'; +import { withRouter } from 'react-router-dom'; +import CustomIcon from 'components/items/CustomIcon'; +import ModuleButton from 'components/items/ModuleButton'; +import styleIdentifiers from './moduleDetailStructure.scss'; + +const styles = classNames.bind(styleIdentifiers); + +export interface StateProps {} + +export interface DispatchProps {} + +export interface OwnProps {} + +export type ModuleDetailStructureProps = StateProps & DispatchProps & OwnProps; + +interface ModuleDetailStructureState {} + +function ModuleDetailStructure(ItemComponent: React.ComponentType) { + class DetailStructure extends React.Component< + ModuleDetailStructureProps, + ModuleDetailStructureState + > { + returnFieldsPart(isOption) { + const { items } = this.props; + + const list = items.filter(e => { + let isInOptions = false; + + if (typeof e === 'object') { + const inArray = Object.values(e); + if (inArray.filter(i => !i.side).length === 0) isInOptions = true; + } + + if (isOption) return isInOptions; + return !isInOptions; + }); + + return list; + } + + renderAction() { + const { actions } = this.props; + if (!actions) return null; + + return ( +
+ {actions.map((item, key) => ( + + ))} +
+ ); + } + + renderHeader() { + const { title, history } = this.props; + if (!title) return null; + + return ( +
+
history.goBack()}> + + +
+
{title}
+
+ ); + } + + render() { + const { actions } = this.props; + return ( +
+
+ {this.renderHeader()} + {this.returnFieldsPart().map((item, key) => ( + + ))} +
+ {(this.returnFieldsPart(true).length !== 0 || actions) && ( +
+ {this.renderAction()} + {this.returnFieldsPart(true).map((item, key) => ( + + ))} +
+ )} +
+ ); + } + } + return withRouter(DetailStructure); +} + +export default ModuleDetailStructure; diff --git a/admin/src/components/enhancers/ModuleDetailStructure/index.tsx b/admin/src/components/enhancers/ModuleDetailStructure/index.tsx new file mode 100644 index 0000000..7cb81d4 --- /dev/null +++ b/admin/src/components/enhancers/ModuleDetailStructure/index.tsx @@ -0,0 +1,3 @@ +import ModuleDetailStructure from './ModuleDetailStructure'; + +export default ModuleDetailStructure; diff --git a/admin/src/components/enhancers/ModuleDetailStructure/moduleDetailStructure.scss b/admin/src/components/enhancers/ModuleDetailStructure/moduleDetailStructure.scss new file mode 100644 index 0000000..1124d90 --- /dev/null +++ b/admin/src/components/enhancers/ModuleDetailStructure/moduleDetailStructure.scss @@ -0,0 +1,57 @@ +@import '~styles/mixins'; +@import '~styles/adminMixins'; + +.ModuleDetailStructure { + display: flex; + height: 100%; + + .general { + flex-grow: 1; + padding: 30px 30px 30px 50px; + } + + .header { + display: flex; + border-bottom: 2px solid $color-border; + padding: 0 0 25px 0; + align-items: center; + + .title { + font-size: 32px; + font-weight: bold; + letter-spacing: 0.6px; + } + + .back { + color: $color_primary; + cursor: pointer; + margin-right: 30px; + + .icon { + margin-right: 15px; + } + } + } + + .options { + min-width: 300px; + padding: 30px; + outline: 2px solid $color-border; + } + + > * { + overflow: auto; + } + + ::-webkit-scrollbar-track { + background: $color-border !important; + } + + ::-webkit-scrollbar { + width: 2px !important; + } + + .action-container { + margin-bottom: 30px; + } +} diff --git a/admin/src/components/formHelpers/GenericFieldArray/GenericFieldArray.stories.tsx b/admin/src/components/formHelpers/GenericFieldArray/GenericFieldArray.stories.tsx new file mode 100644 index 0000000..5042fde --- /dev/null +++ b/admin/src/components/formHelpers/GenericFieldArray/GenericFieldArray.stories.tsx @@ -0,0 +1,19 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import React from 'react'; +import { storiesOf } from '@storybook/react'; +import { reduxForm } from 'redux-form'; +import GenericFieldArray from './GenericFieldArray'; + +storiesOf('admin/formHelpers/GenericFieldArray', module) + .addDecorator(story => { + const Wrapper = reduxForm({ form: 'test' })(() => story()); + return ; + }) + .add('GenericFieldArray component', () => { + const info = { + id: { type: 'string', label: 'id' }, + master: { type: 'multi', label: 'master' }, + tester: { type: 'multi', label: 'tester' }, + }; + return ; + }); diff --git a/admin/src/components/formHelpers/GenericFieldArray/GenericFieldArray.test.tsx b/admin/src/components/formHelpers/GenericFieldArray/GenericFieldArray.test.tsx new file mode 100644 index 0000000..ab05c80 --- /dev/null +++ b/admin/src/components/formHelpers/GenericFieldArray/GenericFieldArray.test.tsx @@ -0,0 +1,13 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { reduxForm } from 'redux-form'; +import GenericFieldArray from './GenericFieldArray'; + +describe('GenericFieldArray', () => { + it('should render without crashing', () => { + const Wrapper = reduxForm({ form: 'test' })(() => ( + {}} /> + )); + shallow(); + }); +}); diff --git a/admin/src/components/formHelpers/GenericFieldArray/GenericFieldArray.tsx b/admin/src/components/formHelpers/GenericFieldArray/GenericFieldArray.tsx new file mode 100644 index 0000000..0d308b3 --- /dev/null +++ b/admin/src/components/formHelpers/GenericFieldArray/GenericFieldArray.tsx @@ -0,0 +1,92 @@ +import React, { useRef } from 'react'; +import classNames from 'classnames/bind'; +import ModuleButton from 'components/items/ModuleButton'; + +import GenericFields from 'components/formHelpers/GenericFields'; +import GenericDropDown from 'components/pageItems/GenericDropDown'; +import get from 'lodash/get'; +import { FormSpy } from 'react-final-form'; +import styleIdentifiers from './genericFieldArray.scss'; + +const styles = classNames.bind(styleIdentifiers); + +export interface StateProps { + infos: Record; +} + +export interface DispatchProps {} + +export interface OwnProps {} + +export type GenericFieldArrayProps = StateProps & DispatchProps & OwnProps; + +const GenericFieldArray = (props: GenericFieldArrayProps) => { + const { fields, item, formValues, infos, resourceData, finalForm, lg, activeLgs } = props; + + function deleteItem(index: number) { + // could add a are you sure + fields.remove(index); + } + + function renderTitle(values, name) { + const value = get(values, `${name}.${item?.dropdownLabel || 'title'}`); + + if (!value) return false; + + return value; + } + + function addField() { + fields.push(); + } + + return ( +
+ {fields && + fields.map( + (name, index): JSX => ( + + {({ values }) => ( + + +
+ deleteItem(index)} + /> +
+
+ )} +
+ ), + )} + +
+ ); +}; + +export default GenericFieldArray; diff --git a/admin/src/components/formHelpers/GenericFieldArray/genericFieldArray.scss b/admin/src/components/formHelpers/GenericFieldArray/genericFieldArray.scss new file mode 100644 index 0000000..4f1736b --- /dev/null +++ b/admin/src/components/formHelpers/GenericFieldArray/genericFieldArray.scss @@ -0,0 +1,11 @@ +@import '~styles/mixins'; +@import '~styles/adminMixins'; + +.GenericFieldArray { + @include genericFields; + + .buttons { + margin-top: 7px; + text-align: right; + } +} diff --git a/admin/src/components/formHelpers/GenericFieldArray/index.ts b/admin/src/components/formHelpers/GenericFieldArray/index.ts new file mode 100644 index 0000000..6d80880 --- /dev/null +++ b/admin/src/components/formHelpers/GenericFieldArray/index.ts @@ -0,0 +1,3 @@ +import GenericFieldArray from './GenericFieldArray'; + +export default GenericFieldArray; diff --git a/admin/src/components/formHelpers/GenericFields/GenericFields.stories.tsx b/admin/src/components/formHelpers/GenericFields/GenericFields.stories.tsx new file mode 100644 index 0000000..bcd43ca --- /dev/null +++ b/admin/src/components/formHelpers/GenericFields/GenericFields.stories.tsx @@ -0,0 +1,44 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import React from 'react'; +import { storiesOf } from '@storybook/react'; +import { text, select } from '@storybook/addon-knobs'; +import { reduxForm } from 'redux-form'; +import GenericFields from './GenericFields'; + +const field = [ + 'string', + 'separator', + 'select', + 'schedule', + 'textarea', + 'multiarea', + 'multi', + 'bool', + 'date', + 'picture', + 'video', + 'wysiwyg', + 'multiwysiwyg', +]; + +storiesOf('admin/formHelpers/GenericFields', module) + .addDecorator( + (story): JSX => { + const Wrapper = reduxForm({ form: 'test' })(() => story()); + return ; + }, + ) + .add( + 'GenericFields component', + (): JSX => ( + {}} + loadOne={(): void => {}} + push={(): void => {}} + upload={(): void => {}} + fields={{ + id: { type: select('type', field, 'string'), label: text('label', 'label') }, + }} + /> + ), + ); diff --git a/admin/src/components/formHelpers/GenericFields/GenericFields.test.tsx b/admin/src/components/formHelpers/GenericFields/GenericFields.test.tsx new file mode 100644 index 0000000..d688da7 --- /dev/null +++ b/admin/src/components/formHelpers/GenericFields/GenericFields.test.tsx @@ -0,0 +1,16 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { reduxForm } from 'redux-form'; + +import GenericFields from './index'; + +describe('GenericFields', (): void => { + it('should render without crashing', (): void => { + const Wrapper = reduxForm({ form: 'test' })( + (): JSX => ( + {}} fields={{ id: { type: 'string', label: 'label' } }} /> + ), + ); + shallow(); + }); +}); diff --git a/admin/src/components/formHelpers/GenericFields/GenericFields.tsx b/admin/src/components/formHelpers/GenericFields/GenericFields.tsx new file mode 100644 index 0000000..32c3a28 --- /dev/null +++ b/admin/src/components/formHelpers/GenericFields/GenericFields.tsx @@ -0,0 +1,869 @@ +import * as React from 'react'; +import classNames from 'classnames/bind'; + +import { Field as ReduxField, FieldArray as ReduxFieldArray } from 'redux-form'; + +import { Field as FinalFormField, FormSpy, Form } from 'react-final-form'; +import { FieldArray as FinalFormFieldArray } from 'react-final-form-arrays'; + +import { composeValidators, geoLocValidation, required } from 'store/utils/validation'; +import InputLanguage from 'components/formHelpers/InputLanguage'; +import GenericFieldArray from 'components/formHelpers/GenericFieldArray'; +import { viewConfig, config } from 'config/general'; +import ModuleButton from 'components/items/ModuleButton'; + +import Input from 'components/formItems/Input'; +import NumberInput from 'components/formItems/NumberInput'; +import Textarea from 'components/formItems/Textarea'; +import UploadZone from 'components/formItems/UploadZone'; +import Password from 'components/formItems/Password'; +import Wysiwyg from 'components/formItems/Wysiwyg'; +import CkEditorField from 'components/formItems/CkEditorField'; +import FileStackInput from 'components/formItems/FileStackInput'; + +import CustomSelect from 'components/formItems/CustomSelect'; +import Checkbox from 'components/formItems/Checkbox'; +import DateTimePicker from 'components/formItems/DateTimePicker'; + +import SwitchInput from 'components/formItems/SwitchInput'; +import ScheduleInput from 'components/formItems/ScheduleInput'; +import { handleVarInString } from 'store/utils/adminHelpers'; +import _ from 'lodash'; +import { clone } from 'store/utils/helper'; +import ColorPicker from 'components/formItems/ColorPicker'; +import TagsInput from 'components/formItems/TagsInput'; +import GeoLocInput from 'components/formItems/GeoLocInput'; +import JsonField from 'components/formItems/JsonField'; +import PriceForm from 'components/forms/PriceForm'; +import FormItemLabel from 'components/items/FormItemLabel'; +import CodeEditorInput from 'components/formItems/CodeEditorInput'; +import GenericDropDown from 'components/pageItems/GenericDropDown'; +import LanguageSelector from 'components/pageItems/LanguageSelector'; +import MultipleUploadZone from 'components/formItems/MultipleUploadZone'; +import styleIdentifiers from './genericFields.scss'; + +const styles = classNames.bind(styleIdentifiers); + +export interface GenericFieldsProps { + fields: Record; + base?: any; + push: Function; + upload: Function; + loadData: Function; + loadOne: Function; + edit?: boolean; + values?: Record; + sectionWrapperClassName: string; + sectionClassName: string; + sectionTitleClassName: string; +} + +interface GenericFieldsState {} + +export default class GenericFields extends React.Component { + constructor(props) { + super(props); + + this.state = { + activeLgs: [config.defaultLanguage], + errors: null, + }; + } + + getName = (item: Record, key: string): string => { + const { base } = this.props; + + const name = item.name || item.dataField || key; + return base ? `${base}.${name}` : name; + }; + + checkIfLanguageSelector = array => { + for (let index = 0; index < array.length; index++) { + const element = array[index]; + if (element.type?.includes('multi')) return true; + if (element.fields) { + const sublevel = Object.values(element.fields); + if (this.checkIfLanguageSelector(sublevel)) { + return true; + } + } + } + return false; + }; + + checkIfErrorIsInSection = section => { + const { errors } = this.state; + if (!errors) return false; + + for (let index = 0; index < Object.keys(errors).length; index++) { + const element = Object.keys(errors)[index]; + return section.includes(element); + } + + return false; + }; + + renderItem = (item: any, key: string, index): JSX => { + const { + edit, + oneSection, + sectionWrapperClassName, + sectionClassName, + sectionTitleClassName, + allOpened, + fieldClassName, + } = this.props; + const { activeLgs } = this.state; + + if (!item) return false; + + if (edit && (item.hideUpdate || item.noUpdate)) return false; + if (!edit && (item.hideCreate || item.noCreate)) return false; + + if (item.type === 'group' || item.type === 'col' || item.type === 'column') { + return ( +
+ +
+ ); + } + + if (item.type === 'section') { + let hasError = false; + hasError = this.checkIfErrorIsInSection(Object.keys(item.fields)); + let arrayFields = item.fields; + if (arrayFields && !Array.isArray(arrayFields)) { + arrayFields = Object.values(arrayFields); + } + if (Object.keys(item.fields)[0] === '0') { + const allName = []; + for (let i = 0; i < Object.values(item.fields).length; i++) { + const element = Object.values(item.fields)[i]; + allName.push(element?.name || element?.label); + } + hasError = this.checkIfErrorIsInSection(allName); + } + + return ( +
+ + {this.checkIfLanguageSelector(arrayFields) && ( + this.setState({ activeLgs: e })} + /> + )} + + +
+ ); + } + + if (item.displayIf || item.hideIf) { + return ( +
+ {({ values }) => this.renderField(item, key, values)} +
+ ); + } + + return ( +
+ {this.renderField(item, key)} +
+ ); + }; + + checkNoMargin = (item: Record): boolean => { + const { edit } = this.props; + + return item.first || item.noMargin || (!edit && item.firstCreate) || (edit && item.firstUpdate); + }; + + renderField = (item: any, key: string, formValues: object): JSX => { + const { + base, + resourceData, + upload, + loadData, + edit, + uploadS3, + finalForm, + propsInput, + uploadApollo, + loadApolloData, + fieldsProps, + dialogShow, + dialogHide, + activeLgs, + lg, + form, + value, + } = this.props; + + // Default inputs props + const inputProps = + (propsInput && viewConfig && { ...propsInput, ...viewConfig.inputProps }) || + (viewConfig && viewConfig.inputProps) || + propsInput || + {}; + + if (!edit && item.type === 'link') return false; + + const type = item.type; + + const name = this.getName(item, key); + + // Override field props + let overwriteProps = {}; + + // Override by type + if (fieldsProps && fieldsProps[type]) overwriteProps = fieldsProps[type]; + + // Override by field name + if (fieldsProps && fieldsProps[name]) { + // extend type fields (if exists) with field type + overwriteProps = { + ...overwriteProps, + ...fieldsProps[name], + }; + } + + const displayIf = item.displayIf; + const hideIf = item.hideIf; + + if (item.hide) return false; + + // Conditional display / Hide + if (displayIf && Array.isArray(displayIf)) { + // 1. check current level + let check = _.get(formValues, `${base}.${displayIf[0]}`); + // 2. check root level + if (!check) check = _.get(formValues, displayIf[0]); + + if (!check || check !== displayIf[1]) { + return false; + } + } else if (displayIf && typeof displayIf === 'object') { + const keys = Object.keys(displayIf); + + for (let i = 0; i < keys.length; i++) { + // 1. check current level + let check = _.get(formValues, `${base}.${keys[i]}`); + // 2. check root level + if (!check) check = _.get(formValues, keys[i]); + + if (!check || check !== displayIf[keys[i]]) { + return false; + } + } + } + + if (hideIf && Array.isArray(hideIf)) { + // 1. check current level + let check = _.get(formValues, `${base}.${hideIf[0]}`); + // 2. check root level + if (!check) check = _.get(formValues, hideIf[0]); + + if (check && check === hideIf[1]) { + return false; + } + } else if (hideIf && typeof hideIf === 'object') { + const keys = Object.keys(hideIf); + for (let i = 0; i < keys.length; i++) { + // 1. check current level + let check = _.get(formValues, `${base}.${keys[i]}`); + // 2. check root level + if (!check) check = _.get(formValues, keys[i]); + if (check && check === hideIf[keys[i]]) { + return false; + } + } + } + + const Field = finalForm ? FinalFormField : ReduxField; + const FieldArray = finalForm ? FinalFormFieldArray : ReduxFieldArray; + + const commonProps = { + ...inputProps, + defaultValue: item.defaultValue, + label: item.label || key, + warning: item.warning, + name, + // override button class (need to be implemented in components) + button: ModuleButton, + validate: item.required ? required : null, + noMargin: this.checkNoMargin(item), + info: item.info, + labelClassName: styles('generic-label', type === 'bool' && 'radio-generic-label'), + }; + + if (type === 'string' || type === 'input' || type === 'text') { + return ( + (item.disabled || type === 'text') && item.onClick && item.onClick(value)} + placeholder={item.placeholder} + initialValue={item.initialValue} + inputType={item.inputType || 'text'} + label={item.label || key} + validate={item.required ? required : null} + {...overwriteProps} + /> + ); + } + if (type === 'textarea') { + return ( + + ); + } + if (type === 'tags') { + return ( + + ); + } + if (type === 'password') { + return ( + + ); + } + if (type === 'switch') { + return ( + + ); + } + if (type === 'number') { + return ( + + ); + } + if (type === 'link') { + return ( + + ); + } + if (type === 'multi') { + return ( + + ); + } + if (type === 'multiarea') { + return ( + + ); + } + if (type === 'multiwysiwyg') { + let uploadFunc; + if (item.s3Upload) uploadFunc = uploadS3; + if (item.apollo) uploadFunc = uploadApollo; + else uploadFunc = upload; + + return ( + + ); + } + if (type === 'multicode') { + return ( + + ); + } + + if (type === 'separator') { + return ( +
+

{item.label}

+
+ ); + } + if (type === 'price') { + return ( + + dialogShow({ + id: 'custom', + large: true, + children: ( +
{ + dialogHide('custom'); + if (form) { + form.change('price', val.price); + form.change('currency', val.currency); + form.change('vatClassId', val.vatClassId); + } + }} + /> + ), + }) + } + /> + ); + } + if (type === 'datetime' || type === 'date') { + return ( + + ); + } + + if (type === 'list') { + return ( +
+ {item.label && ( +
+

{item.label}

+
+ )} +
+ +
+
+ ); + } + if (type === 'select') { + return ( + + ); + } + if (type === 'geoloc') { + let validation; + if (finalForm) { + if (item.required) { + validation = composeValidators(item.required && required, geoLocValidation); + } else validation = geoLocValidation; + } else { + validation = [geoLocValidation]; + if (item.required) validation.push(required); + } + return ( + + ); + } + if (type === 'bool') { + return ( + + ); + } + if (type === 'color') { + return ( + + ); + } + if (type === 'filestack') { + return ( + + ); + } + + if (type === 'schedule') { + return ( + + ); + } + if (type === 'wysiwyg') { + let uploadFunc; + if (item.s3Upload) uploadFunc = uploadS3; + if (item.apollo) uploadFunc = uploadApollo; + else uploadFunc = upload; + + return ( + + ); + } + if (type === 'wysiwyg2') { + return ( + + ); + } + if (type === 'code') { + return ( + + ); + } + + // File part + let filePrefix = item.s3UploadPrefix || item.uploadPrefix || item.prefix; + filePrefix = filePrefix && handleVarInString(resourceData || formValues, filePrefix); + if (filePrefix) { + filePrefix = filePrefix + .toLowerCase() + .split(' ') + .join('_'); + } + + let uploadFunc; + if (item.s3Upload) uploadFunc = uploadS3; + if (item.apollo) uploadFunc = uploadApollo; + else uploadFunc = upload; + + const uploadProps = { + component: UploadZone, + s3Upload: item.s3Upload, + uploadPrefix: filePrefix, + noFileName: item.noFileName || item.noRename, + upload: uploadFunc, + noUnique: item.noUnique, + apollo: item.apollo, + uploadPath: item.apiPath, + asObject: item.asObject, + showProgress: item.showProgress, + treatmentFile: item.treatmentFile, + saveValue: item.saveValue, + subInfo: item.subInfo, + inputs: item.inputs, + noUpload: item.noUpload, + displayValue: item.displayValue || false, + }; + + if (type === 'file') { + return ( + + ); + } + + if (type === 'video') { + return ( + + ); + } + + if (type === 'picture') { + return ( + + ); + } + if (type === 'multiFiles') { + return ( + + ); + } + + if (type === 'multiPictures') { + return ( + + ); + } + + if (type === 'json') { + return ( +
+ {item.label && ( +
+ {item.label} +
+ )} + +
+ ); + } + return false; + }; + + render(): JSX { + const { fields, item, className } = this.props; + const data = fields || item; + const keys = Object.keys(data); + + return ( +
+ { + if (e?.submitFailed) this.setState({ errors: e?.errors }); + }} + /> + {keys.map((key, index) => this.renderItem(data[key], key, index))} +
+ ); + } +} diff --git a/admin/src/components/formHelpers/GenericFields/genericFields.scss b/admin/src/components/formHelpers/GenericFields/genericFields.scss new file mode 100644 index 0000000..507c4ed --- /dev/null +++ b/admin/src/components/formHelpers/GenericFields/genericFields.scss @@ -0,0 +1,61 @@ +@import '~styles/mixins'; +@import '~styles/adminMixins'; + +.GenericFields { + @include genericFields; + + .section { + &.section-error { + box-shadow: 4px 4px 10px 0 rgba($color: $color_error, $alpha: 0.4); + } + } + + .section-title { + &.section-title-error { + color: $color_error; + } + } + + .generic-label { + color: $color_primary; + letter-spacing: 0; + &.radio-generic-label { + color: black; + } + } + + .list-values { + margin-top: 10px; + + &.no-margin { + margin-top: 0; + } + + button { + margin-top: 10px; + } + } + + .active { + background-color: $color_primary; + border-color: $color_primary; + } + + .separator { + @include separator; + } + + .label-switch { + font-weight: initial; + } + + .label { + display: block; + color: #8c96a3; + text-transform: uppercase; + font-size: 10px; + font-weight: 600; + pointer-events: none; + margin-top: 20px; + } +} diff --git a/admin/src/components/formHelpers/GenericFields/index.ts b/admin/src/components/formHelpers/GenericFields/index.ts new file mode 100644 index 0000000..7c471c8 --- /dev/null +++ b/admin/src/components/formHelpers/GenericFields/index.ts @@ -0,0 +1,27 @@ +import { connect } from 'react-redux'; + +import generic from 'store/generic'; +import genericApollo from 'store/genericApollo'; +import file from 'store/file'; +import app from 'store/app'; +import GenericFields, { GenericFieldsProps } from './GenericFields'; + +const mapStateToProps = (state): Record => ({ + content: state.content.raw, + lg: state.content.lg, +}); + +const mapDispatchToProps = { + loadData: generic.actions.search.request.action, + loadApolloData: genericApollo.actions.search.request.action, + loadOne: generic.actions.detail.request.action, + upload: generic.actions.upload.request.action, + uploadS3: generic.actions.uploadS3Url.request.action, + uploadApollo: file.actions.upload.request.action, + dialogShow: app.actions.dialog.show.action, + dialogHide: app.actions.dialog.hide.action, +}; + +const Wrapped = connect(mapStateToProps, mapDispatchToProps)(GenericFields); + +export default Wrapped; diff --git a/admin/src/components/formHelpers/InputLanguage/InputLanguage.stories.tsx b/admin/src/components/formHelpers/InputLanguage/InputLanguage.stories.tsx new file mode 100644 index 0000000..06293c5 --- /dev/null +++ b/admin/src/components/formHelpers/InputLanguage/InputLanguage.stories.tsx @@ -0,0 +1,27 @@ + +import React from 'react'; +import { storiesOf } from '@storybook/react'; +import { reduxForm } from 'redux-form'; +import { object } from '@storybook/addon-knobs'; +import InputLanguage from './InputLanguage'; + + +storiesOf('admin/formHelpers/InputLanguage', module) + .addDecorator(story => { + + const Wrapper = reduxForm({ form: 'test' })(() => story()); + return ; + }) + .add('default', () => ) + .add('custom', () => ( + + )); diff --git a/admin/src/components/formHelpers/InputLanguage/InputLanguage.test.tsx b/admin/src/components/formHelpers/InputLanguage/InputLanguage.test.tsx new file mode 100644 index 0000000..e6a6c50 --- /dev/null +++ b/admin/src/components/formHelpers/InputLanguage/InputLanguage.test.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { reduxForm } from 'redux-form'; + +import InputLanguage from './InputLanguage'; + +describe('InputLanguage', () => { + it('should render without crashing', () => { + const Wrapper = reduxForm({ form: 'test' })(() => ( + + )); + shallow(); + }); +}); diff --git a/admin/src/components/formHelpers/InputLanguage/InputLanguage.tsx b/admin/src/components/formHelpers/InputLanguage/InputLanguage.tsx new file mode 100644 index 0000000..a5524a9 --- /dev/null +++ b/admin/src/components/formHelpers/InputLanguage/InputLanguage.tsx @@ -0,0 +1,233 @@ +import * as React from 'react'; +import classNames from 'classnames/bind'; +// import TextItem from 'components/items/TextItem'; +import { Field as FinalField } from 'react-final-form'; +import { Field as ReduxField } from 'redux-form'; + +import Input from 'components/formItems/Input'; +import Textarea from 'components/formItems/Textarea'; +import Wysiwyg from 'components/formItems/Wysiwyg'; +import CkEditorField from 'components/formItems/CkEditorField'; +import CodeEditorInput from 'components/formItems/CodeEditorInput'; + +import { config } from 'config/general'; +import { required } from 'store/utils/validation'; +import FormItemLabel from 'components/items/FormItemLabel'; +import _ from 'lodash'; +import { FaLock, FaUnlock } from 'react-icons/fa'; +import ModuleButton from 'components/items/ModuleButton'; +import styleIdentifiers from './inputLanguage.scss'; + +const styles = classNames.bind(styleIdentifiers); + +export interface InputLanguageProps { + item?: Record; + name: string; + type?: string; + inputProps?: Record; + noMargin?: boolean; +} + +interface InputLanguageState {} + +export default class InputLanguage extends React.Component { + constructor(props) { + super(props); + + const { lg } = this.props; + + const firstDiff = _.find(config.languages, it => { + const l = it.short || it.value; + return l !== lg; + }); + + this.state = { + displayAllLanguages: false, + mainLanguage: lg, + currentLg: firstDiff ? firstDiff.short || firstDiff.value : lg, + }; + } + + getComponent = () => { + const { type } = this.props; + if (type === 'textarea') return Textarea; + if (type === 'wysiwyg') return CkEditorField; + if (type === 'code') return CodeEditorInput; + + return Input; + }; + + checkMatch = lgCode => { + const { matches, matchClassName } = this.props; + + if (!matches) return ''; + + if (matches.indexOf(lgCode) >= 0) { + return matchClassName; + } + + return ''; + }; + + switchLanguage = target => { + const { currentLg, mainLanguage } = this.state; + + // switch main language + if (currentLg === target) { + this.setState({ + mainLanguage: target, + currentLg: mainLanguage, + }); + return; + } + this.setState({ + currentLg: target, + }); + }; + + toggleDisplayLanguages = () => { + const { displayAllLanguages } = this.state; + this.setState({ + displayAllLanguages: !displayAllLanguages, + }); + }; + + render() { + const { + redux, + item, + label, + name, + type, + inputProps, + noMargin, + lock, + locked, + activeLgs, + ...rest + } = this.props; + + const { currentLg, displayAllLanguages, mainLanguage } = this.state; + + const Field = redux ? ReduxField : FinalField; + + const numLg = config.languages.length; + + let languages = + numLg <= config.maxNumLanguagesDisplayed + ? config.languages + : [_.find(config.languages, lg => lg.short === mainLanguage || lg.value === mainLanguage)]; + if (displayAllLanguages) languages = config.languages; + + if (activeLgs) languages = languages.filter(e => activeLgs?.includes(e.value || e.short)); + + return ( +
+ {label && } + {languages.map((lang, index) => { + const lgCode = lang.value || lang.short; + return ( +
2 && 'large')} + > +
{lgCode}
+
+ + {lock && ( +
+ {locked ? : } +
+ )} +
+
+ ); + })} + {numLg > config.maxNumLanguagesDisplayed && ( + <> + {!displayAllLanguages && ( +
2 && 'large')}> +
{currentLg}
+
+ + {lock && ( +
+ {locked ? : } +
+ )} +
+
+ )} +
+ {!displayAllLanguages && + config.languages.map((l, key) => { + const lgCode = l.short || l.value; + // if (label === currentLg) return false; + if (lgCode === mainLanguage) return false; + return ( +
this.switchLanguage(lgCode)} + className={styles('language-label', currentLg === lgCode && 'active')} + > + {lgCode} +
+ ); + })} + +
+ + )} +
+ ); + } +} diff --git a/admin/src/components/formHelpers/InputLanguage/index.ts b/admin/src/components/formHelpers/InputLanguage/index.ts new file mode 100644 index 0000000..6f6e68c --- /dev/null +++ b/admin/src/components/formHelpers/InputLanguage/index.ts @@ -0,0 +1,13 @@ +import { connect } from 'react-redux'; + +import InputLanguage from './InputLanguage'; + +const mapStateToProps = (state: StoreProps) => ({ + lg: state.content.lg, +}); + +const mapDispatchToProps = {}; + +const Wrapped = connect(mapStateToProps, mapDispatchToProps)(InputLanguage); + +export default Wrapped; diff --git a/admin/src/components/formHelpers/InputLanguage/inputLanguage.scss b/admin/src/components/formHelpers/InputLanguage/inputLanguage.scss new file mode 100644 index 0000000..84f7936 --- /dev/null +++ b/admin/src/components/formHelpers/InputLanguage/inputLanguage.scss @@ -0,0 +1,76 @@ +@import '~styles/mixins'; + +.InputLanguage { + .language-wrapper { + margin-top: 8px; + position: relative; + padding-left: 17px; + + &.large { + padding-left: 43px; + } + + .language-label { + position: absolute; + left: 0; + text-transform: uppercase; + top: 11px; + opacity: 0.4; + font-size: 9px; + font-weight: bold; + } + } + + margin-top: $form_spacing; + + &.no-margin { + margin-top: 0; + } + + .field-wrapper-container { + position: relative; + + .icon-locker { + @include v_align; + display: flex; + right: 5px; + padding: 4px; + font-size: 12px; + opacity: 0.2; + cursor: pointer; + + &.with-wysiwyg { + bottom: 5px; + bottom: 5px; + top: auto; + transform: none; + } + + &.active { + opacity: 1; + } + } + } + .language-switch { + margin-top: 10px; + display: flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + .button { + margin: 4px 0; + margin-left: 10px; + } + .language-label { + font-weight: bold; + text-transform: uppercase; + font-size: 12px; + padding: 0 4px; + opacity: 0.4; + cursor: pointer; + &.active { + opacity: 1; + } + } + } +} diff --git a/admin/src/components/formItems/CkEditorField/CkEditorField.stories.tsx b/admin/src/components/formItems/CkEditorField/CkEditorField.stories.tsx new file mode 100644 index 0000000..ea824d2 --- /dev/null +++ b/admin/src/components/formItems/CkEditorField/CkEditorField.stories.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import { storiesOf } from '@storybook/react'; +import { reduxForm, Field } from 'redux-form'; +import { text, object, color, dom, array } from '@storybook/addon-knobs'; +import CkEditorField from './CkEditorField'; + +storiesOf('seed/formItems/CkEditorField', module) + .addDecorator(story => { + const Wrapper = reduxForm({ form: 'test' })(() => story()); + return ; + }) + .add('Normal', () => ); diff --git a/admin/src/components/formItems/CkEditorField/CkEditorField.test.tsx b/admin/src/components/formItems/CkEditorField/CkEditorField.test.tsx new file mode 100644 index 0000000..9bdfde5 --- /dev/null +++ b/admin/src/components/formItems/CkEditorField/CkEditorField.test.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import CkEditorField from './CkEditorField'; + +describe('CkEditorField', () => { + it('should render without crashing', () => { + shallow(); + }); +}); diff --git a/admin/src/components/formItems/CkEditorField/CkEditorField.tsx b/admin/src/components/formItems/CkEditorField/CkEditorField.tsx new file mode 100644 index 0000000..473dcb5 --- /dev/null +++ b/admin/src/components/formItems/CkEditorField/CkEditorField.tsx @@ -0,0 +1,253 @@ +import * as React from 'react'; +import classNames from 'classnames/bind'; +import TextItem from 'components/items/TextItem'; + +import CKEditor from '@ckeditor/ckeditor5-react'; +import ClassicEditor from '@ckeditor/ckeditor5-build-classic'; +import { getFileName } from 'store/utils/api'; + +import FormElement from 'components/structure/FormElement'; +import FieldWrapper from 'components/structure/FieldWrapper'; + +import Cropper from 'components/global/Cropper'; + +import CustomIcon from 'components/items/CustomIcon'; +import { FaExpandAlt } from 'react-icons/fa'; +import CodeEditorInput from 'components/formItems/CodeEditorInput'; +import styleIdentifiers from './ckEditorField.scss'; +import UploadAdapter from './UploadAdapter'; + +const styles = classNames.bind(styleIdentifiers); + +type CkEditorFieldProps = FormElementProps & + FieldWrapperProps & + FormItemLabelProps & { + name: string; + placeholder?: string; + dialogShow: Function; + dialogHide: Function; + aspectRatio?: number; + upload: Function; + uploadUrl: string; + noResize?: boolean; + }; + +interface CkEditorFieldState { + code: boolean; +} + +export default class CkEditorField extends React.Component { + constructor(props: CkEditorFieldProps) { + super(props); + this.state = { + code: false, + }; + } + + // Add a custom adapter + myAdapter = (editor: any) => { + const { dialogHide } = this.props; + + editor.plugins.get('FileRepository').createUploadAdapter = loader => + // Attach a custom upload adapter and provide the loader + new UploadAdapter({ + loader, + onFileSelected: this.onFileSelected, + cancel: () => dialogHide('custom'), + }); + }; + + onFileSelected = (file: File, callback: Function, progressFunc?: Function) => { + const self = this; + + const { noResize } = this.props; + + if (noResize) { + this.uploadImage(file, callback, progressFunc); + return; + } + // read file + const reader = new FileReader(); + + reader.onload = () => { + const fileAsBinaryString = reader.result; + + self.resizeImage(fileAsBinaryString, callback, progressFunc); + }; + + reader.onabort = () => { + callback({ + error: true, + message: 'file reading was aborted', + }); + }; + reader.onerror = () => { + callback({ + error: true, + message: 'file reading has failed', + }); + }; + reader.readAsDataURL(file); + }; + + // Use cropper to resize + resizeImage = (binary: string, callback: Function, progressFunc?: Function) => { + const { dialogShow, aspectRatio, apollo } = this.props; + // return; + dialogShow({ + id: 'custom', + medium: true, + noBackgroundClose: true, + closeButton: 'light', + closePosition: 'right', + children: ( + this.uploadImage(file, callback, progressFunc)} + /> + ), + }); + }; + + uploadImage = (file: File, callback: Function, progressFunc?: Function) => { + const { dialogHide, upload, uploadUrl, apollo, uploadPrefix } = this.props; + + if (apollo && upload) { + upload({ + data: { + title: getFileName(uploadPrefix, file, false), + base64: file, + }, + callback, + }); + } else if (!apollo) { + if (uploadUrl && upload) { + upload({ + url: uploadUrl, + file, + fileName: getFileName(uploadPrefix, file, false), + callback, + noLoading: true, + callbackError: errorData => { + callback({ + ...errorData, + error: true, + message: 'upload failed, server issue', + }); + }, + progress: data => { + if (progressFunc) progressFunc(data); + }, + }); + } else { + callback({ + error: true, + message: 'no upload data provided', + }); + } + } + + dialogHide('custom'); + }; + + handleChange = (event: KeyboardEvent, editor: Record) => { + const { input } = this.props; + + const data = editor.getData(); + + if (input) input.onChange(data); + }; + + switchType = () => { + const { code } = this.state; + + this.setState({ + code: !code, + }); + }; + + renderInput = () => { + const { code } = this.state; + const { input, disabled } = this.props; + return !code ? ( + { + this.editor = editor; + }} + disabled={disabled} + onChange={this.handleChange} + /> + ) : ( +
+ +
+ ); + }; + + openFullScreen = () => { + const { dialogShow } = this.props; + + dialogShow({ + id: 'wysiwyg', + style: { zIndex: 10 }, + large: true, + children:
{this.renderInput()}
, + }); + }; + + /* insertImage = () => { + if (!this.editor) return; + console.log(this.editor); + this.editor.execute('insertImage', { + src: + 'https://s3.eu-central-1.amazonaws.com/tipaw-pictures/f851775c-314b-449f-ac26-ce4dd6d93b89.jpg', + }); + const { input } = this.props; + + let data = this.editor.getData(); + + data += + ""; + input.onChange(data); + }; */ + + render() { + const { className, input, valueClassName, disabled, name, effect, placeholder } = this.props; + const { code } = this.state; + return ( + + +
+
+ +
+ {!code && ( +
+ +
+ )} +
+ {this.renderInput()} +
+
+ ); + } +} diff --git a/admin/src/components/formItems/CkEditorField/UploadAdapter.tsx b/admin/src/components/formItems/CkEditorField/UploadAdapter.tsx new file mode 100644 index 0000000..7ba978d --- /dev/null +++ b/admin/src/components/formItems/CkEditorField/UploadAdapter.tsx @@ -0,0 +1,44 @@ +export default class UploadAdapter { + constructor(props) { + // The file loader instance to use during the upload. + this.loader = props.loader; + this.onFileSelected = props.onFileSelected; + this.cancel = props.cancel; + } + + progressFunc = data => { + this.loader.uploadTotal = data.total; + this.loader.uploaded = data.loaded; + }; + + // Starts the upload process. (must return a Promise) + // see https://ckeditor.com/docs/ckeditor5/latest/framework/guides/deep-dive/upload-adapter.html + upload() { + return this.loader.file.then( + file => + new Promise((resolve, reject) => { + this.onFileSelected( + file, + response => { + if (response.error) { + reject(response.message); + console.log('error upload', response); + } else { + // could be formated in a function given to the constructor + resolve({ + // other sizes can be provided + default: response.url || response.large, + }); + } + }, + this.progressFunc, + ); + }), + ); + } + + // Aborts the upload process. + abort() { + if (this.cancel) this.cancel(); + } +} diff --git a/admin/src/components/formItems/CkEditorField/ckEditorField.scss b/admin/src/components/formItems/CkEditorField/ckEditorField.scss new file mode 100644 index 0000000..7b0bbba --- /dev/null +++ b/admin/src/components/formItems/CkEditorField/ckEditorField.scss @@ -0,0 +1,126 @@ +@import '~styles/mixins'; +@import '~styles/adminMixins'; + +$margin_top: 8px; +$height_small: 350px; +$height_bar: 75px; + +.CkEditorField { + line-height: 1.3; + + @include baseTableStyle; + + .editor-wrapper { + position: relative; + } + + .top-actions { + padding-top: $margin_top; + text-align: right; + align-items: center; + + > * { + @include middle; + margin-left: 10px; + cursor: pointer; + } + + user-select: none; + font-size: 10px; + z-index: 1; + text-transform: uppercase; + padding-bottom: 5px; + + .insert-image { + font-size: 13px; + } + + .open-full { + border-left: 1px solid $color-border; + padding-left: 10px; + } + + &.code { + border-bottom: 1px solid #f1f1f1; + } + } + + textarea { + padding: 0; + } + .code-input-wrapper { + padding: 10px 0; + } + + :global .ck { + font-family: $font; + color: $color_dark; + + .ck-toolbar { + border: 1px solid $color_border !important; + background-color: white !important; + } + + .ck-editor__main > .ck-editor__editable { + border: none !important; + box-shadow: none !important; + background-color: transparent; + } + + .ck.ck-sticky-panel .ck-sticky-panel__content_sticky { + top: $height_global_header - 15px; + box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.2); + } + + p { + margin-bottom: revert; + } + } + + .editor-wrapper.small { + :global .ck { + .ck-editor__main > .ck-editor__editable { + max-height: 400px; + } + } + } +} +.cke-modal { + text-align: left; + height: 80vh; + .code-input-wrapper { + padding: 10px; + } + + :global { + .ck-editor { + height: 100% !important; + + .ck-editor__main { + height: calc(100% - 38px) !important; + overflow-y: scroll; + + &::-webkit-scrollbar { + width: 5px; + } + + &::-webkit-scrollbar-track { + background-color: rgba($color: #888, $alpha: 0.3); + } + + &::-webkit-scrollbar-thumb { + border-radius: 2px; + background-color: rgba($color: #888, $alpha: 0.5); + } + + &::-webkit-scrollbar-thumb:hover { + background-color: rgba($color: #888, $alpha: 0.6); + } + + .ck-content { + border: none !important; + } + } + } + } +} diff --git a/admin/src/components/formItems/CkEditorField/index.ts b/admin/src/components/formItems/CkEditorField/index.ts new file mode 100644 index 0000000..07a7e57 --- /dev/null +++ b/admin/src/components/formItems/CkEditorField/index.ts @@ -0,0 +1,14 @@ +import { connect } from 'react-redux'; +import app from 'store/app'; +import CkEditorField from './CkEditorField'; + +const mapStateToProps = () => ({}); + +const mapDispatchToProps = { + dialogShow: app.actions.dialog.show.action, + dialogHide: app.actions.dialog.hide.action, +}; + +const Wrapped = connect(mapStateToProps, mapDispatchToProps)(CkEditorField); + +export default Wrapped; diff --git a/admin/src/components/formItems/JsonField/JsonField.stories.tsx b/admin/src/components/formItems/JsonField/JsonField.stories.tsx new file mode 100644 index 0000000..34201ec --- /dev/null +++ b/admin/src/components/formItems/JsonField/JsonField.stories.tsx @@ -0,0 +1,7 @@ +import React from 'react'; +import { storiesOf } from '@storybook/react'; +import { text, object, color, dom, array } from '@storybook/addon-knobs'; + +import JsonField from './index'; + +storiesOf('newComponents/JsonField', module).add('Default', () => ); diff --git a/admin/src/components/formItems/JsonField/JsonField.test.tsx b/admin/src/components/formItems/JsonField/JsonField.test.tsx new file mode 100644 index 0000000..ff07ab4 --- /dev/null +++ b/admin/src/components/formItems/JsonField/JsonField.test.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import JsonField from './index'; + +describe('JsonField', () => { + it('should render without crashing', () => { + shallow(); + }); +}); diff --git a/admin/src/components/formItems/JsonField/index.tsx b/admin/src/components/formItems/JsonField/index.tsx new file mode 100644 index 0000000..86320db --- /dev/null +++ b/admin/src/components/formItems/JsonField/index.tsx @@ -0,0 +1,99 @@ +import React, { useState, useEffect, useRef } from 'react'; + +// Redux part +import { useSelector, useDispatch } from 'react-redux'; +import { StoreState } from 'store/rootReducer'; + +// Import actions here + +import classNames from 'classnames/bind'; +import TextItem from 'components/items/TextItem'; +import GenericFields from 'components/formHelpers/GenericFields'; +import ModuleButton from 'components/items/ModuleButton'; +import styleIdentifiers from './jsonField.scss'; + +const styles = classNames.bind(styleIdentifiers); + +export interface StateProps {} + +export interface DispatchProps {} + +export interface OwnProps {} + +export type JsonFieldProps = StateProps & DispatchProps & OwnProps; + +const JsonField = (props: JsonFieldProps) => { + const { input } = props; + + const [jsonString, setJsonString] = useState(''); + + const refInput = useRef(); + + function safelyParseJSON(json) { + // This function cannot be optimised, it's best to + // keep it small! + let parsed; + + try { + parsed = JSON.parse(json); + } catch (e) { + return `Error: ${e.message}`; + } + + return parsed; // Could be undefined! + } + + function handleUserKeyPress() { + let value = input.value; + let position; + + if (value.length !== 0) { + if (value[0] !== '{') value = `{${value}`; + if (value[value.length - 1] !== '}') { + value = `${value}}`; + position = value.length - 1; + } + } + + value = value.replace(/'/g, '"'); + + input.onChange(value); + + if (position) refInput.current.setSelectionRange(position, position); + } + + useEffect(() => { + window.addEventListener('keyup', handleUserKeyPress); + + return () => { + window.removeEventListener('keyup', handleUserKeyPress); + }; + }); + + useEffect(() => { + input.onChange(JSON.stringify(input.value)); + }, []); + + return ( +
+
+