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 && (
-
-
-
- )}
-
-
-
-
-
+
+ {err && (
+
+
+
+ )}
+
+
+
+
);
};
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;