diff --git a/ashes/src/components/bulk-actions/bulk-actions.jsx b/ashes/src/components/bulk-actions/bulk-actions.jsx index 1684f5f394..a03c0b0acf 100644 --- a/ashes/src/components/bulk-actions/bulk-actions.jsx +++ b/ashes/src/components/bulk-actions/bulk-actions.jsx @@ -14,8 +14,10 @@ import { getStore } from 'lib/store-creator'; import SelectAdminsModal from '../users/select-modal'; -const mapDispatchToProps = (dispatch, {module}) => { - const {actions} = getStore(`${module}.bulk`); +const mapDispatchToProps = (dispatch, {bulkModule, module}) => { + const { actions } = bulkModule + ? getStore(bulkModule) + : getStore(`${module}.bulk`); return { bulkActions: bindActionCreators(actions, dispatch), @@ -25,6 +27,7 @@ const mapDispatchToProps = (dispatch, {module}) => { @connect(void 0, mapDispatchToProps) export default class BulkActions extends Component { static propTypes = { + bulkModule: PropTypes.string, module: PropTypes.string.isRequired, entity: PropTypes.string.isRequired, actions: PropTypes.arrayOf(PropTypes.array).isRequired, diff --git a/ashes/src/components/bulk-actions/bulk-messages.jsx b/ashes/src/components/bulk-actions/bulk-messages.jsx index 822dbd7e98..4867dc235b 100644 --- a/ashes/src/components/bulk-actions/bulk-messages.jsx +++ b/ashes/src/components/bulk-actions/bulk-messages.jsx @@ -15,6 +15,7 @@ import ErrorAlerts from '../alerts/error-alerts'; type Props = { storePath: string, + bulkModule?: string, module: string, entity: string, renderDetail: () => ReactElement, @@ -95,8 +96,11 @@ const mapState = (state, { storePath }) => ({ bulk: get(state, storePath, {}), }); -const mapActions = (dispatch, { module }) => ({ - bulkActions: bindActionCreators(getStore(`${module}.bulk`).actions, dispatch), -}); +const mapActions = (dispatch, { bulkModule, module }) => { + const { actions } = bulkModule ? getStore(bulkModule) : getStore(`${module}.bulk`); + return { + bulkActions: bindActionCreators(actions, dispatch), + }; +}; export default connect(mapState, mapActions)(BulkMessages); diff --git a/ashes/src/components/catalog/catalog-page.jsx b/ashes/src/components/catalog/catalog-page.jsx index cb2c2ef86b..8ac10654b6 100644 --- a/ashes/src/components/catalog/catalog-page.jsx +++ b/ashes/src/components/catalog/catalog-page.jsx @@ -8,10 +8,11 @@ import { createSelector } from 'reselect'; import { transitionTo, transitionToLazy } from 'browserHistory'; // components +import Content from 'components/core/content/content'; import PageNav from 'components/core/page-nav'; import SaveCancel from 'components/core/save-cancel'; import WaitAnimation from 'components/common/wait-animation'; -import { IndexLink } from 'components/link'; +import { IndexLink, Link } from 'components/link'; import { PageTitle } from 'components/section-title'; // data @@ -84,7 +85,6 @@ class CatalogPage extends Component { componentWillReceiveProps(nextProps: Props) { if (nextProps.params.catalogId !== 'new' && nextProps.catalog) { const { name, site, countryId, defaultLanguage } = nextProps.catalog; - this.setState({ name, site, countryId, defaultLanguage }); } } @@ -93,6 +93,15 @@ class CatalogPage extends Component { const { catalogId } = this.props.params; const params = { catalogId }; + let links = null; + if (!this.isNew) { + links = ( + + Products + + ); + } + return ( Details + {links} ); } @@ -175,7 +185,9 @@ class CatalogPage extends Component { /> {this.localNav} - {upChildren} + + {upChildren} + ); } diff --git a/ashes/src/components/catalog/details.jsx b/ashes/src/components/catalog/details.jsx index 2c843a2572..79f55be0cb 100644 --- a/ashes/src/components/catalog/details.jsx +++ b/ashes/src/components/catalog/details.jsx @@ -3,7 +3,6 @@ import _ from 'lodash'; import React from 'react'; -import Content from 'components/core/content/content'; import ContentBox from 'components/content-box/content-box'; import { Dropdown } from 'components/dropdown'; import ErrorAlerts from 'components/alerts/error-alerts'; @@ -42,62 +41,60 @@ const CatalogDetails = (props: Props) => { const languageItems = languages.map((lang) => [lang, lang]); return ( - -
- {err && ( -
- -
- )} - -
- - onChange('name', v)} - value={name} - /> - - - onChange('site', v)} - value={site} - /> - - - onChange('countryId', c)} - /> - - - onChange('defaultLanguage', l)} - /> - -
-
-
-
+
+ {err && ( +
+ +
+ )} + +
+ + onChange('name', v)} + value={name} + /> + + + onChange('site', v)} + value={site} + /> + + + onChange('countryId', c)} + /> + + + onChange('defaultLanguage', l)} + /> + +
+
+
); }; diff --git a/ashes/src/components/catalog/products.css b/ashes/src/components/catalog/products.css new file mode 100644 index 0000000000..129d1dccfa --- /dev/null +++ b/ashes/src/components/catalog/products.css @@ -0,0 +1,7 @@ +.list-container { + margin-top: -10px; +} + +.list-container :global(.fc-live-search) { + padding: 0; +} diff --git a/ashes/src/components/catalog/products.jsx b/ashes/src/components/catalog/products.jsx new file mode 100644 index 0000000000..d61a467398 --- /dev/null +++ b/ashes/src/components/catalog/products.jsx @@ -0,0 +1,246 @@ +/* @flow */ + +// libs +import _ from 'lodash'; +import React, { Component } from 'react'; +import { bindActionCreators } from 'redux'; +import { connect } from 'react-redux'; +import { filterArchived } from 'elastic/archive'; +import * as dsl from 'elastic/dsl'; +import { bulkExportBulkAction, renderExportModal } from 'modules/bulk-export/helpers'; + +// actions +import { actions } from 'modules/catalog/products-list'; +import { linkProducts, unlinkProduct } from 'modules/catalog/details'; +import { bulkExport } from 'modules/bulk-export/bulk-export'; +import { actions as bulkActions } from 'modules/taxons/details/bulk'; + +// components +import { SectionTitle } from 'components/section-title'; +import SelectableSearchList from 'components/list-page/selectable-search-list'; +import ProductRow from 'components/products/product-row'; +import { makeTotalCounter } from 'components/list-page'; +import { ProductsAddModal } from 'components/products-add'; +import { Button } from 'components/core/button'; +import BulkActions from 'components/bulk-actions/bulk-actions'; +import BulkMessages from 'components/bulk-actions/bulk-messages'; +import { Link } from 'components/link'; + +// styles +import styles from './products.css'; + +type Props = { + params: { + catalogId: number, + }, + actions: { + addSearchFilters: Function, + fetch: Function, + linkProducts: Function, + unlinkProduct: Function, + setExtraFilters: Function, + }, + bulkActions: { + exportByIds: Function, + }, + bulkExportAction: Function, + list: Object, + linkState: Object, + unlinkState: Object, +} + +type State = { + modalVisible: boolean, + deletedProductId: ?number, +}; + +class CatalogProducts extends Component { + props: Props; + + state: State = { + modalVisible: false, + deletedProductId: null, + }; + + componentDidMount() { + const { catalogId } = this.props.params; + + this.props.actions.setExtraFilters([ + dsl.nestedTermFilter('catalogs.id', catalogId), + ]); + + this.props.actions.fetch(); + } + + get bulkActions(): Array { + return [ + bulkExportBulkAction(this.bulkExport, 'Products'), + ]; + } + + get tableColumns(): Columns { + return [ + { field: 'productId', text: 'ID' }, + { field: 'image', text: 'Image', type: 'image' }, + { field: 'title', text: 'Name' }, + { field: 'skus', text: 'SKUs' }, + { field: 'state', text: 'State' }, + { field: '', render: this.unlinkButton }, + ]; + } + + bulkExport = (allChecked: boolean, toggledIds: Array) => { + const { exportByIds } = this.props.bulkActions; + const modalTitle = 'Products'; + const entity = 'products'; + + return renderExportModal(this.tableColumns, entity, modalTitle, exportByIds, toggledIds); + }; + + renderBulkDetails = (context: string, id: number) => { + const { list } = this.props; + const results = list.currentSearch().results.rows; + const filteredProduct = _.filter(results, (product) => product.id.toString() === id)[0]; + const productId = filteredProduct.productId; + + return ( + + Product {productId} + + ); + }; + + unlinkButton = (children: any, row: Product) => { + const inProgress = this.props.unlinkState.inProgress + && this.state.deletedProductId === row.productId; + + return ( + + ); + }; + + openModal = () => this.setState({ modalVisible: true }); + closeModal = () => this.setState({ modalVisible: false }); + + handleAddProduct = (product: Product) => { + const { actions, params: { catalogId } } = this.props; + actions.linkProducts(catalogId, { productIds: [product.productId] }). + then(this.props.actions.fetch); + }; + + handleUnlinkProduct = (product: Product, e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const { actions, params: { catalogId } } = this.props; + const { productId } = product; + + this.setState({ deletedProductId: productId }, () => { + actions.unlinkProduct(catalogId, productId).then(this.props.actions.fetch); + }); + }; + + renderRow(row: Product, index: number, columns: Columns, params: Object) { + const id = row.productId != null ? row.productId : 0; + const key = `catalog-product-${id}`; + + return ( + + ); + } + + addSearchFilters = (filters: Array, initial: boolean = false) => { + return this.props.actions.addSearchFilters(filterArchived(filters), initial); + }; + + render() { + const { list, actions, linkState } = this.props; + const products = _.get(list, ['savedSearches', 0, 'results', 'rows']); + + const searchActions = { + ...actions, + addSearchFilters: this.addSearchFilters, + }; + + const TotalCounter = makeTotalCounter(state => _.get(state, 'catalogs.products'), actions); + + return ( +
+ } + addTitle="Product" + onAddClick={this.openModal} + /> + + + id} + /> + + +
+ ); + } +} + +const mapStateToProps = (state) => { + return { + list: _.get(state, 'catalogs.products', {}), + linkState: _.get(state.asyncActions, 'catalogLinkProducts', {}), + unlinkState: _.get(state.asyncActions, 'catalogUnlinkProduct', {}), + }; +}; + +const mapDispatchToProps = (dispatch) => { + return { + actions: { + ...bindActionCreators(actions, dispatch), + linkProducts: bindActionCreators(linkProducts, dispatch), + unlinkProduct: bindActionCreators(unlinkProduct, dispatch), + }, + bulkExportAction: bindActionCreators(bulkExport, dispatch), + bulkActions: bindActionCreators(bulkActions, dispatch), + }; +}; + +export default connect(mapStateToProps, mapDispatchToProps)(CatalogProducts); diff --git a/ashes/src/components/core/content/content.css b/ashes/src/components/core/content/content.css index 039d46ae3f..3f37704c34 100644 --- a/ashes/src/components/core/content/content.css +++ b/ashes/src/components/core/content/content.css @@ -1,3 +1,3 @@ .body { - padding: 20px; + margin: 40px 1.85% 40px 1.85%; } diff --git a/ashes/src/components/products-add/products-add.jsx b/ashes/src/components/products-add/products-add.jsx index fa5f79cd94..17d2f7404e 100644 --- a/ashes/src/components/products-add/products-add.jsx +++ b/ashes/src/components/products-add/products-add.jsx @@ -91,6 +91,7 @@ class ProductsAdd extends Component { className={styles.search} value={this.props.search} onChange={this.handleInputChange} + placeholder="Filter products..." autoFocus /> diff --git a/ashes/src/components/typeahead/input.jsx b/ashes/src/components/typeahead/input.jsx index 8613e2ba2b..50040426a5 100644 --- a/ashes/src/components/typeahead/input.jsx +++ b/ashes/src/components/typeahead/input.jsx @@ -23,7 +23,6 @@ const TypeaheadInput = ({ className, isFetching = false, ...rest }: Props) => { return ( - Api.patch(`/catalogs/${id}`, payload) ); +const _linkProducts = createAsyncActions( + 'catalogLinkProducts', + (catalogId: number, payload: any) => Api.post(`/catalogs/${catalogId}/products`, payload) +); + +const _unlinkProduct = createAsyncActions( + 'catalogUnlinkProduct', + (catalogId: number, productId: number) => Api.delete(`/catalogs/${catalogId}/products/${productId}`) +); + export const fetchCatalog = _fetchCatalog.perform; export const createCatalog = _createCatalog.perform; export const updateCatalog = _updateCatalog.perform; +export const linkProducts = _linkProducts.perform; +export const unlinkProduct = _unlinkProduct.perform; const handleResponse = (state, response) => ({ ...state, catalog: response }); diff --git a/ashes/src/modules/catalog/index.js b/ashes/src/modules/catalog/index.js index a6ac6ac24c..eac34c0e8c 100644 --- a/ashes/src/modules/catalog/index.js +++ b/ashes/src/modules/catalog/index.js @@ -7,11 +7,13 @@ import { combineReducers } from 'redux'; import details from './details'; import list from './list'; import bulk from './bulk'; +import products from './products-list'; const catalogReducer = combineReducers({ details, list, bulk, + products, }); export default catalogReducer; diff --git a/ashes/src/modules/catalog/products-list.js b/ashes/src/modules/catalog/products-list.js new file mode 100644 index 0000000000..ae05de8bcc --- /dev/null +++ b/ashes/src/modules/catalog/products-list.js @@ -0,0 +1,21 @@ +/* @flow */ + +import makeLiveSearch from 'modules/live-search'; +import productsSearchTerms from 'modules/products/search-terms'; + +const { reducer, actions } = makeLiveSearch( + 'catalogs.products', + productsSearchTerms, + 'products_search_view/_search', + 'productsScope', + { + initialState: { sortBy: 'name' }, + rawSorts: ['name'], + skipInitialFetch: true, + } +); + +export { + reducer as default, + actions, +}; diff --git a/ashes/src/routes/catalog.js b/ashes/src/routes/catalog.js index 6a1de5326a..cc969c1259 100644 --- a/ashes/src/routes/catalog.js +++ b/ashes/src/routes/catalog.js @@ -29,6 +29,7 @@ import SkuImages from 'components/skus/images'; import CatalogListWrapper from 'components/catalog/list-wrapper'; import CatalogList from 'components/catalog/list'; import CatalogDetails from 'components/catalog/details'; +import CatalogProducts from 'components/catalog/products'; import CatalogPage from 'components/catalog/catalog-page'; const getRoutes = (jwt: Object) => { @@ -97,6 +98,11 @@ const getRoutes = (jwt: Object) => { component: CatalogPage, }, [ router.read('catalog-details', { isIndex: true, component: CatalogDetails }), + router.read('catalog-products', { + path: 'products', + title: 'Products', + component: CatalogProducts, + }), ]), ]); diff --git a/green-river/src/main/scala/consumer/elastic/mappings/ProductsCatalogView.scala b/green-river/src/main/scala/consumer/elastic/mappings/ProductsCatalogView.scala index 6126444410..6a0eea191b 100644 --- a/green-river/src/main/scala/consumer/elastic/mappings/ProductsCatalogView.scala +++ b/green-river/src/main/scala/consumer/elastic/mappings/ProductsCatalogView.scala @@ -32,8 +32,12 @@ final case class ProductsCatalogView()(implicit ec: EC) extends AvroTransformer field("taxonomies").nested( field("taxons", StringType).analyzer("upper_cased"), field("taxonomy", StringType).analyzer("upper_cased") + ), + field("catalogs").nested( + field("id", IntegerType), + field("name", StringType).index("not_analyzed") ) ) - override def nestedFields() = List("albums", "tags", "skus", "taxonomies") + override def nestedFields() = List("albums", "tags", "skus", "taxonomies", "catalogs") } diff --git a/green-river/src/main/scala/consumer/elastic/mappings/admin/ProductsSearchView.scala b/green-river/src/main/scala/consumer/elastic/mappings/admin/ProductsSearchView.scala index a9bc8b841e..c82a04169c 100644 --- a/green-river/src/main/scala/consumer/elastic/mappings/admin/ProductsSearchView.scala +++ b/green-river/src/main/scala/consumer/elastic/mappings/admin/ProductsSearchView.scala @@ -37,8 +37,12 @@ final case class ProductsSearchView()(implicit ec: EC) extends AvroTransformer { field("title", StringType).index("not_analyzed"), field("baseUrl", StringType).index("not_analyzed") ) + ), + field("catalogs").nested( + field("id", IntegerType), + field("name", StringType).index("not_analyzed") ) ) - override def nestedFields() = List("albums", "skus", "tags", "taxonomies", "taxons") + override def nestedFields() = List("albums", "skus", "tags", "taxonomies", "taxons", "catalogs") } diff --git a/phoenix-scala/phoenix/app/phoenix/failures/CatalogFailures.scala b/phoenix-scala/phoenix/app/phoenix/failures/CatalogFailures.scala index a837fd967e..a82a518413 100644 --- a/phoenix-scala/phoenix/app/phoenix/failures/CatalogFailures.scala +++ b/phoenix-scala/phoenix/app/phoenix/failures/CatalogFailures.scala @@ -7,4 +7,9 @@ object CatalogFailures { def apply(id: Int) = NotFoundFailure404(s"Catalog $id not found") } + + object ProductNotFoundInCatalog { + def apply(catalogId: Int, productId: Int) = + NotFoundFailure404(s"Product $productId not found in catalog $catalogId") + } } diff --git a/phoenix-scala/phoenix/app/phoenix/models/catalog/CatalogProduct.scala b/phoenix-scala/phoenix/app/phoenix/models/catalog/CatalogProduct.scala new file mode 100644 index 0000000000..60fff55fff --- /dev/null +++ b/phoenix-scala/phoenix/app/phoenix/models/catalog/CatalogProduct.scala @@ -0,0 +1,45 @@ +package phoenix.models.catalog + +import java.time.Instant + +import shapeless._ +import core.db._ +import core.db.ExPostgresDriver.api._ + +case class CatalogProduct(id: Int, + catalogId: Int, + productId: Int, + createdAt: Instant, + archivedAt: Option[Instant]) + extends FoxModel[CatalogProduct] + +object CatalogProduct { + def buildSeq(catalogId: Int, productIds: Seq[Int]): Seq[CatalogProduct] = + productIds.map( + productId ⇒ + CatalogProduct(id = 0, + catalogId = catalogId, + productId = productId, + createdAt = Instant.now, + archivedAt = None)) +} + +class CatalogProducts(tag: Tag) extends FoxTable[CatalogProduct](tag, "catalog_products") { + def id = column[Int]("id", O.PrimaryKey, O.AutoInc) + def catalogId = column[Int]("catalog_id") + def productId = column[Int]("product_id") + def createdAt = column[Instant]("created_at") + def archivedAt = column[Option[Instant]]("archived_at") + + def * = + (id, catalogId, productId, createdAt, archivedAt) <> ((CatalogProduct.apply _).tupled, CatalogProduct.unapply) +} + +object CatalogProducts + extends FoxTableQuery[CatalogProduct, CatalogProducts](new CatalogProducts(_)) + with ReturningId[CatalogProduct, CatalogProducts] { + val returningLens: Lens[CatalogProduct, Int] = lens[CatalogProduct].id + + def filterProduct(catalogId: Int, productId: Int): QuerySeq = + filter(cp ⇒ cp.catalogId === catalogId && cp.productId === productId) +} diff --git a/phoenix-scala/phoenix/app/phoenix/payloads/CatalogPayloads.scala b/phoenix-scala/phoenix/app/phoenix/payloads/CatalogPayloads.scala index 051609bc33..36334ce184 100644 --- a/phoenix-scala/phoenix/app/phoenix/payloads/CatalogPayloads.scala +++ b/phoenix-scala/phoenix/app/phoenix/payloads/CatalogPayloads.scala @@ -11,4 +11,6 @@ object CatalogPayloads { site: Option[String] = None, countryId: Option[Int] = None, defaultLanguage: Option[String] = None) + + case class AddProductsPayload(productIds: Seq[Int]) } diff --git a/phoenix-scala/phoenix/app/phoenix/routes/admin/CatalogRoutes.scala b/phoenix-scala/phoenix/app/phoenix/routes/admin/CatalogRoutes.scala index ca2677a04a..c8cd9fac60 100644 --- a/phoenix-scala/phoenix/app/phoenix/routes/admin/CatalogRoutes.scala +++ b/phoenix-scala/phoenix/app/phoenix/routes/admin/CatalogRoutes.scala @@ -4,7 +4,7 @@ import akka.http.scaladsl.server.Directives._ import akka.http.scaladsl.server.Route import phoenix.utils.http.JsonSupport._ import phoenix.models.account.User -import phoenix.payloads.CatalogPayloads.{CreateCatalogPayload, UpdateCatalogPayload} +import phoenix.payloads.CatalogPayloads._ import phoenix.services.Authenticator.AuthData import phoenix.services.catalog.CatalogManager import phoenix.utils.aliases._ @@ -31,6 +31,20 @@ object CatalogRoutes { mutateOrFailures { CatalogManager.updateCatalog(catalogId, payload) } + } ~ + pathPrefix("products") { + (post & pathEnd & entity(as[AddProductsPayload])) { payload ⇒ + mutateOrFailures { + CatalogManager.addProductsToCatalog(catalogId, payload) + } + } ~ + pathPrefix(IntNumber) { productId ⇒ + (delete & pathEnd) { + deleteOrFailures { + CatalogManager.removeProductFromCatalog(catalogId, productId) + } + } + } } } } diff --git a/phoenix-scala/phoenix/app/phoenix/services/LogActivity.scala b/phoenix-scala/phoenix/app/phoenix/services/LogActivity.scala index 1e0c03caad..701a28f7cc 100644 --- a/phoenix-scala/phoenix/app/phoenix/services/LogActivity.scala +++ b/phoenix-scala/phoenix/app/phoenix/services/LogActivity.scala @@ -39,7 +39,7 @@ import phoenix.responses.users.{CustomerResponse, UserResponse} import phoenix.services.carts.CartLineItemUpdater.foldQuantityPayload import phoenix.services.activity.AssignmentsTailored._ import phoenix.services.activity.CartTailored._ -import phoenix.services.activity.CatalogTailored.{CatalogCreated, CatalogUpdated} +import phoenix.services.activity.CatalogTailored._ import phoenix.services.activity.CategoryTailored._ import phoenix.services.activity.CouponsTailored._ import phoenix.services.activity.CustomerGroupsTailored._ @@ -493,6 +493,20 @@ case class LogActivity(implicit ac: AC) { )(implicit ec: EC): DbResultT[Activity] = Activities.log(CatalogUpdated(UserResponse.build(admin), catalog)) + def productsAddedToCatalog( + admin: User, + catalog: CatalogResponse.Root, + productIds: Seq[Int] + )(implicit ec: EC): DbResultT[Activity] = + Activities.log(ProductsAddedToCatalog(buildUser(admin), catalog, productIds)) + + def productRemovedFromCatalog( + admin: User, + catalogId: Int, + productId: Int + )(implicit ec: EC): DbResultT[Activity] = + Activities.log(ProductRemovedFromCatalog(buildUser(admin), catalogId, productId)) + /* Products */ def fullProductCreated(admin: Option[User], product: ProductResponse.Root, diff --git a/phoenix-scala/phoenix/app/phoenix/services/activity/CatalogTailored.scala b/phoenix-scala/phoenix/app/phoenix/services/activity/CatalogTailored.scala index e0d6e8bf5f..ae70abf766 100644 --- a/phoenix-scala/phoenix/app/phoenix/services/activity/CatalogTailored.scala +++ b/phoenix-scala/phoenix/app/phoenix/services/activity/CatalogTailored.scala @@ -11,4 +11,12 @@ object CatalogTailored { case class CatalogUpdated(admin: UserResponse, catalog: CatalogResponse.Root) extends ActivityBase[CatalogUpdated] + case class ProductsAddedToCatalog(admin: UserResponse.Root, + catalog: CatalogResponse.Root, + productIds: Seq[Int]) + extends ActivityBase[ProductsAddedToCatalog] + + case class ProductRemovedFromCatalog(admin: UserResponse.Root, catalogId: Int, productId: Int) + extends ActivityBase[ProductRemovedFromCatalog] + } diff --git a/phoenix-scala/phoenix/app/phoenix/services/catalog/CatalogManager.scala b/phoenix-scala/phoenix/app/phoenix/services/catalog/CatalogManager.scala index 413a9d7d23..6ef55fa955 100644 --- a/phoenix-scala/phoenix/app/phoenix/services/catalog/CatalogManager.scala +++ b/phoenix-scala/phoenix/app/phoenix/services/catalog/CatalogManager.scala @@ -1,5 +1,7 @@ package phoenix.services.catalog +import java.time.Instant + import cats.data._ import cats.implicits._ import com.typesafe.scalalogging.LazyLogging @@ -10,7 +12,7 @@ import phoenix.payloads.CatalogPayloads._ import phoenix.responses.CatalogResponse._ import phoenix.utils.aliases._ import core.db._ -import phoenix.failures.CatalogFailures.CatalogNotFound +import phoenix.failures.CatalogFailures._ import phoenix.services.LogActivity object CatalogManager extends LazyLogging { @@ -38,4 +40,25 @@ object CatalogManager extends LazyLogging { response = build(catalog, country) _ ← * <~ LogActivity().withScope(ac.ctx.scope).catalogUpdated(au.model, response) } yield response + + def addProductsToCatalog( + catalogId: Int, + payload: AddProductsPayload)(implicit ec: EC, db: DB, ac: AC, au: AU): DbResultT[Root] = + for { + catalog ← * <~ getCatalog(catalogId) + _ ← * <~ CatalogProducts.createAll(CatalogProduct.buildSeq(catalog.id, payload.productIds)) + _ ← * <~ LogActivity() + .withScope(ac.ctx.scope) + .productsAddedToCatalog(au.model, catalog, payload.productIds) + } yield catalog + + def removeProductFromCatalog(catalogId: Int, + productId: Int)(implicit ec: EC, db: DB, ac: AC, au: AU): DbResultT[Unit] = + for { + catalogProduct ← * <~ CatalogProducts + .filterProduct(catalogId, productId) + .mustFindOneOr(ProductNotFoundInCatalog(catalogId, productId)) + _ ← * <~ CatalogProducts.update(catalogProduct, catalogProduct.copy(archivedAt = Some(Instant.now))) + _ ← * <~ LogActivity().withScope(ac.ctx.scope).productRemovedFromCatalog(au.model, catalogId, productId) + } yield () } diff --git a/phoenix-scala/phoenix/test/integration/CatalogIntegrationTest.scala b/phoenix-scala/phoenix/test/integration/CatalogIntegrationTest.scala index 3fff5bfb63..d6bdd5248a 100644 --- a/phoenix-scala/phoenix/test/integration/CatalogIntegrationTest.scala +++ b/phoenix-scala/phoenix/test/integration/CatalogIntegrationTest.scala @@ -1,3 +1,5 @@ +import core.db._ +import phoenix.models.catalog._ import phoenix.payloads.CatalogPayloads._ import phoenix.responses.CatalogResponse import testutils._ @@ -72,4 +74,49 @@ class CatalogIntegrationTest } } + "POST /v1/catalogs/:id/products" - { + "succeeds in adding the product to the catalog" in new Catalog_ApiFixture with ProductSku_ApiFixture { + val payload = AddProductsPayload(productIds = Seq(product.id)) + catalogsApi(catalog.id).addProducts(payload).mustBeOk + + val catalogProduct = CatalogProducts.filterProduct(catalog.id, product.id).gimme.head + catalogProduct.archivedAt must === (None) + } + + "fails with invalid catalog" in new Catalog_ApiFixture with ProductSku_ApiFixture { + val payload = AddProductsPayload(productIds = Seq(product.id)) + catalogsApi(100).addProducts(payload).mustHaveStatus(404) + } + + "fails with invalid product" in new Catalog_ApiFixture with ProductSku_ApiFixture { + val payload = AddProductsPayload(productIds = Seq(100)) + catalogsApi(catalog.id).addProducts(payload).mustHaveStatus(400) + } + } + + "DELETE /v1/catalogs/:id/products" - { + "succeeds in removing a product to the catalog" in new Catalog_ApiFixture with ProductSku_ApiFixture { + val payload = AddProductsPayload(productIds = Seq(product.id)) + catalogsApi(catalog.id).addProducts(payload).mustBeOk + catalogsApi(catalog.id).deleteProduct(product.id) + + val catalogProduct = CatalogProducts.filterProduct(catalog.id, product.id).gimme.head + catalogProduct.archivedAt must !==(None) + } + + "fails to remove a product that was not in a catalog" in new Catalog_ApiFixture + with ProductSku_ApiFixture { + catalogsApi(catalog.id).deleteProduct(product.id).mustHaveStatus(404) + } + + "fails with invalid catalog" in new Catalog_ApiFixture with ProductSku_ApiFixture { + catalogsApi(100).deleteProduct(product.id).mustHaveStatus(404) + } + + "fails with invalid product" in new Catalog_ApiFixture with ProductSku_ApiFixture { + catalogsApi(100).deleteProduct(product.id).mustHaveStatus(404) + } + + } + } diff --git a/phoenix-scala/phoenix/test/integration/testutils/apis/PhoenixAdminApi.scala b/phoenix-scala/phoenix/test/integration/testutils/apis/PhoenixAdminApi.scala index 99c155b888..89087ae0a4 100644 --- a/phoenix-scala/phoenix/test/integration/testutils/apis/PhoenixAdminApi.scala +++ b/phoenix-scala/phoenix/test/integration/testutils/apis/PhoenixAdminApi.scala @@ -9,7 +9,7 @@ import phoenix.payloads.ActivityTrailPayloads._ import phoenix.payloads.AddressPayloads._ import phoenix.payloads.AssignmentPayloads._ import phoenix.payloads.CartPayloads._ -import phoenix.payloads.CatalogPayloads.{CreateCatalogPayload, UpdateCatalogPayload} +import phoenix.payloads.CatalogPayloads._ import phoenix.payloads.CategoryPayloads._ import phoenix.payloads.CouponPayloads._ import phoenix.payloads.CustomerGroupPayloads._ @@ -895,6 +895,12 @@ trait PhoenixAdminApi extends HttpSupport { self: FoxSuite ⇒ def update(payload: UpdateCatalogPayload)(implicit aa: TestAdminAuth): HttpResponse = PATCH(catalogPath, payload, aa.jwtCookie.some) + + def addProducts(payload: AddProductsPayload)(implicit aa: TestAdminAuth): HttpResponse = + POST(s"$catalogPath/products", payload, aa.jwtCookie.some) + + def deleteProduct(productId: Int)(implicit aa: TestAdminAuth): HttpResponse = + DELETE(s"$catalogPath/products/$productId", aa.jwtCookie.some) } object captureApi { diff --git a/phoenix-scala/sql/R__products_catalog_view_functions.sql b/phoenix-scala/sql/R__products_catalog_view_functions.sql index fa0aa78624..d9c6e7a1ab 100644 --- a/phoenix-scala/sql/R__products_catalog_view_functions.sql +++ b/phoenix-scala/sql/R__products_catalog_view_functions.sql @@ -166,3 +166,33 @@ create trigger update_products_cat_from_product_sku_links_delete_trigger after delete on product_sku_links for each row execute procedure update_products_cat_from_product_sku_links_delete_fn(); + +create or replace function update_products_catalog_view_catalogs_fn() returns trigger as $$ +begin + update products_catalog_view + set + catalogs = case when catalogProducts.catalogs is null then + '[]' :: jsonb + else + catalogProducts.catalogs + end + from ( + select array_to_json(array_agg(row_to_json(cat))) :: jsonb as catalogs + from ( + select c.id, c.name + from catalogs as c + inner join catalog_products as cp on (cp.catalog_id = c.id) + where cp.archived_at is null and cp.product_id = new.product_id + ) as cat + ) as catalogProducts + where products_catalog_view.product_id = new.product_id; + + return null; +end; +$$ language plpgsql; + +drop trigger if exists update_products_catalog_view_catalogs on catalog_products; +create trigger update_products_catalog_view_catalogs +after insert or update on catalog_products + for each row + execute procedure update_products_catalog_view_catalogs_fn(); diff --git a/phoenix-scala/sql/R__products_search_view_triggers.sql b/phoenix-scala/sql/R__products_search_view_triggers.sql index 2a57d2ae0e..bcfdc2189d 100644 --- a/phoenix-scala/sql/R__products_search_view_triggers.sql +++ b/phoenix-scala/sql/R__products_search_view_triggers.sql @@ -51,6 +51,30 @@ begin end; $$ language plpgsql; +create or replace function update_products_search_view_catalogs_fn() returns trigger as $$ +begin + update products_search_view + set + catalogs = case when catalogProducts.catalogs is null then + '[]' :: jsonb + else + catalogProducts.catalogs + end + from ( + select array_to_json(array_agg(row_to_json(cat))) :: jsonb as catalogs + from ( + select c.id, c.name + from catalogs as c + inner join catalog_products as cp on (cp.catalog_id = c.id) + where cp.archived_at is null and cp.product_id = new.product_id + ) as cat + ) as catalogProducts + where products_search_view.product_id = new.product_id; + + return null; +end; +$$ language plpgsql; + drop trigger if exists update_products_search_view_taxonomies on product_taxon_links; create trigger update_products_search_view_taxonomies after insert or update or delete on product_taxon_links @@ -64,3 +88,10 @@ after update on taxonomy_taxon_links for each row when (old.full_path is distinct from new.full_path) execute procedure update_products_search_view_taxonomies_fn(); + +drop trigger if exists update_products_search_view_catalogs on catalog_products; +create trigger update_products_search_view_catalogs +after insert or update on catalog_products + for each row + execute procedure update_products_search_view_catalogs_fn(); + diff --git a/phoenix-scala/sql/V5.20170613151737__create_catalog_products_table.sql b/phoenix-scala/sql/V5.20170613151737__create_catalog_products_table.sql new file mode 100644 index 0000000000..50a6368081 --- /dev/null +++ b/phoenix-scala/sql/V5.20170613151737__create_catalog_products_table.sql @@ -0,0 +1,10 @@ +create table catalog_products( + id serial primary key, + catalog_id integer not null references catalogs(id) on update restrict on delete restrict, + product_id integer not null references object_forms(id) on update restrict on delete restrict, + created_at generic_timestamp not null, + archived_at generic_timestamp +); + +create index catalogs_id_idx on catalog_products (catalog_id); +create index products_id_idx on catalog_products (product_id); diff --git a/phoenix-scala/sql/V5.20170613165711__add_catalogs_to_products_search_view.sql b/phoenix-scala/sql/V5.20170613165711__add_catalogs_to_products_search_view.sql new file mode 100644 index 0000000000..53b0e0b837 --- /dev/null +++ b/phoenix-scala/sql/V5.20170613165711__add_catalogs_to_products_search_view.sql @@ -0,0 +1,2 @@ +alter table products_search_view add column catalogs jsonb; +alter table products_catalog_view add column catalogs jsonb;