Created
September 8, 2021 01:02
-
-
Save hanishi/db58f6fca78d420f8cb8d8d514855410 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import PropTypes from 'prop-types'; | |
import React, {useCallback, useEffect, useState} from "react"; | |
import { | |
Badge, | |
Box, Button, | |
Checkbox, | |
Container, | |
Divider, | |
Grid, | |
IconButton, | |
InputAdornment, | |
makeStyles, | |
MenuItem, | |
SvgIcon, | |
Switch, | |
Table, | |
TableBody, | |
TableCell, | |
TableHead, | |
TablePagination, | |
TableRow, | |
TableSortLabel, | |
TextField, | |
Tooltip, | |
Typography | |
} from "@material-ui/core"; | |
import {isEmpty, truncate} from "lodash"; | |
import {SearchIcon} from "@material-ui/data-grid"; | |
import {useDispatch, useSelector} from "react-redux"; | |
import {fetchEdgeNodes} from "src/slices/edgeNodes"; | |
import useIsMountedRef from "src/hooks/useIsMountedRef"; | |
import {useSnackbar} from "notistack"; | |
import {useTimer} from 'use-timer'; | |
import PerfectScrollbar from "react-perfect-scrollbar"; | |
import {Formik} from 'formik'; | |
import {Cancel, LocalOffer, LocalOfferOutlined} from "@material-ui/icons"; | |
import axios from "src/utils/axios"; | |
import {hostConfig} from "src/config"; | |
import {useParams} from "react-router"; | |
import {fetchTags} from "src/slices/tags"; | |
import moment from "moment"; | |
import {fetchContest} from "src/slices/contest"; | |
const PAUSED = 8; | |
const NOMINATED = 4; | |
const UNKNOWN = 2; | |
const CANDIDATE = 1; | |
const useStyles = makeStyles((theme) => ({ | |
root: { | |
padding: '2px 4px', | |
display: 'flex' | |
}, | |
divider: { | |
height: 56, | |
}, | |
icon: { | |
height: 48, | |
marginTop: 4, | |
margin: 4 | |
} | |
})); | |
const validTags = (tag) => /\+999999999-12-31/.test(tag.tagDate) || moment(tag.tagDate).isAfter(moment()) | |
const dateTime = (name) => name.match(/^tag:.+:(\d+)$/)[1]; | |
const descendingTagComparator = (a, b) => { | |
const timestampA = dateTime(a.name); | |
const timestampB = dateTime(b.name); | |
return timestampB - timestampA || a.id < b.id ? 1 : a.id > b.id ? -1 : 0; | |
} | |
const descendingComparator = (a, b, orderBy) => (b[orderBy] < a[orderBy]) ? -1 : (b[orderBy] > a[orderBy]) ? 1 : 0; | |
const getComparator = (order, orderBy) => order === 'desc' ? | |
(a, b) => descendingComparator(a, b, orderBy) : (a, b) => -descendingComparator(a, b, orderBy); | |
const stableSort = (array, comparator) => { | |
const stabilizedThis = array.map((el, index) => [el, index]); | |
stabilizedThis.sort((a, b) => { | |
const order = comparator(a[0], b[0]); | |
if (order !== 0) return order; | |
return a[1] - b[1]; | |
}); | |
return stabilizedThis.map((el) => el[0]); | |
} | |
const headCells = [ | |
{id: 'name', label: '広告', width: 480}, | |
{id: 'effectiveStatus', label: '配信', width: 160}, | |
{id: 'contest', label: '配信ログ', width: 80} | |
]; | |
const renderStatus = (value) => | |
({ | |
'ACTIVE': 'アクティブ', | |
'PAUSED': 'オフ', | |
'DELETED': 'オフ', | |
'PENDING_REVIEW': 'オフ', | |
'DISAPPROVED': 'オフ: 却下', | |
'PREAPPROVED': 'オフ', | |
'PENDING_BILLING_INFO': 'オフ', | |
'CAMPAIGN_PAUSED': 'オフ: キャンペーン', | |
'ARCHIVED': 'オフ', | |
'ADSET_PAUSED': 'オフ: 広告セット', | |
'IN_PROCESS': 'オフ', | |
'WITH_ISSUES': 'オフ' | |
})[value]; | |
const renderName = (className, matchWords, value, color, length = 60) => value && value.length > length ? | |
<Tooltip title={value} placement='top-start' className={className}> | |
{<Badge color='secondary' variant='dot' anchorOrigin={{ | |
vertical: 'top', | |
horizontal: 'left', | |
}} invisible={!matchWords(value)}><Typography | |
color={color}>{truncate(value, {length: length})}</Typography></Badge> | |
} | |
</Tooltip> : <div className={className}> | |
{<Badge color='secondary' variant='dot' anchorOrigin={{ | |
vertical: 'top', | |
horizontal: 'left', | |
}} invisible={!matchWords(value)}><Typography color={color}>{value}</Typography></Badge> | |
}</div>; | |
const labelId = (id) => `table-view-controller-${id}`; | |
const Row = ({ | |
accountId, | |
campaignId, | |
adsetId, | |
ad, | |
matchWords, | |
tagName, | |
disabled = true, | |
selected, | |
onClick, | |
className, | |
enqueueSnackbar, setLastModified | |
}) => { | |
return <TableRow key={ad.id} | |
hover={!disabled} | |
onClick={onClick} | |
role="checkbox" | |
aria-checked={selected} | |
tabIndex={-1}> | |
{disabled && tagName ? | |
<TableCell padding="checkbox"> | |
<Tooltip title={tagName.match(/^tag:(.+):\d+$/)[1]} placement="left-start" arrow> | |
<span> | |
<Checkbox checked={true} | |
disabled={true} | |
icon={<LocalOfferOutlined/>} | |
checkedIcon={<LocalOffer/>} | |
/> | |
</span> | |
</Tooltip> | |
</TableCell> | |
: disabled ? <TableCell padding="checkbox"/> : | |
<TableCell padding="checkbox"> | |
<Checkbox | |
checked={selected} | |
icon={<LocalOfferOutlined/>} | |
checkedIcon={<LocalOffer/>} | |
/> | |
</TableCell> | |
} | |
{/*<TableCell style={{width: 40}} padding="none">*/} | |
{/* <Formik initialValues={{status: ad.status === 'ACTIVE'}}*/} | |
{/* onSubmit={async (values, actions) => {*/} | |
{/* const value = values.status && 'ACTIVE' || 'PAUSED';*/} | |
{/* actions.setSubmitting(true);*/} | |
{/* axios.post(`${hostConfig.baseUrl}/api/${ad.id}`, {*/} | |
{/* status: value,*/} | |
{/* accountId,*/} | |
{/* campaignId,*/} | |
{/* adsetId*/} | |
{/* }).then(result => {*/} | |
{/* actions.setSubmitting(false);*/} | |
{/* enqueueSnackbar(`広告を${values.status && 'オン' || 'オフ'}にしました`, {variant: 'success'});*/} | |
{/* setLastModified(Date.now());*/} | |
{/* }).catch(error => {*/} | |
{/* actions.setSubmitting(false);*/} | |
{/* actions.resetForm({status: !values.status});*/} | |
{/* enqueueSnackbar(error?.message || '未知のエラー', {variant: 'error'})*/} | |
{/* });*/} | |
{/* }*/} | |
{/* }>*/} | |
{/* {formik =>*/} | |
{/* <Switch size='small' checked={formik.values.status} onChange={(e) => {*/} | |
{/* !formik.handleChange(e) && formik.submitForm();*/} | |
{/* }}*/} | |
{/* name='status'/>*/} | |
{/* }*/} | |
{/* </Formik>*/} | |
{/*</TableCell>*/} | |
<TableCell component="th" id={labelId(ad.id)} scope="row"> | |
<Box>{renderName(className, matchWords, ad.name, ad.color)}</Box> | |
</TableCell> | |
<TableCell> | |
<Box>{renderStatus(ad.effectiveStatus)}</Box> | |
</TableCell> | |
<TableCell> | |
<Box>{ad.contest === 'paused' && ad.status === 'PAUSED' && '停止済' || ''}</Box> | |
</TableCell> | |
</TableRow> | |
} | |
const Results = ({ | |
className, | |
adsetId, | |
words = [] | |
}) => { | |
const isMountedRef = useIsMountedRef(); | |
const classes = useStyles(); | |
const {accountId, campaignId} = useParams(); | |
const {enqueueSnackbar} = useSnackbar(); | |
const [tag, setTag] = useState('none'); | |
const [query, setQuery] = useState(''); | |
const [pages, setPages] = useState({previousPage: 0, currentPage: 0}); | |
const [limit, setLimit] = useState(10); | |
const [order, setOrder] = useState('asc'); | |
const [orderBy, setOrderBy] = useState('status'); | |
const [labelEnabled, setLabelEnabled] = useState(false); | |
const [selectables, setSelectables] = useState([]); | |
const [initialSelected, setInitialSelected] = useState([]); | |
const [selected, setSelected] = useState([]); | |
const [lastModified, setLastModified] = useState(null); | |
const {time, start, pause, reset, status} = useTimer({ | |
endTime: 300, onTimeOver: () => setLastModified(Date.now()) | |
}); | |
const rContestLabels = RegExp(`^${adsetId}:\\d+(?::\\d+)?$`) | |
const handleRequestSort = (event, property) => { | |
const isAsc = orderBy === property && order === 'asc'; | |
setOrder(isAsc ? 'desc' : 'asc'); | |
setOrderBy(property); | |
}; | |
const createSortHandler = (property) => (event) => handleRequestSort(event, property); | |
const { | |
currentEdgeNodes, | |
totalCount, | |
hasPreviousPage, | |
hasNextPage, | |
isLoading: edgeNodesIsLoading, | |
error: edgeRequestError | |
} = useSelector((state) => state.edgeNodes); | |
const { | |
adlabel, | |
expiration, | |
isLoading: contestIsLoading, | |
error: contestRequestError | |
} = useSelector((state) => state.contest); | |
const {tags, isLoading: tagIsLoading, error: tagsRequestError} = useSelector((state) => state.tags); | |
const edgeRequestErrorMessage = edgeRequestError?.message || edgeRequestError; | |
if (edgeRequestErrorMessage) enqueueSnackbar(edgeRequestErrorMessage, {variant: 'error'}); | |
const errorMessageTags = tagsRequestError?.message || tagsRequestError; | |
if (errorMessageTags) enqueueSnackbar(errorMessageTags, {variant: 'error'}); | |
const errorContestRequest = contestRequestError?.message || contestRequestError; | |
if (errorContestRequest) enqueueSnackbar(errorContestRequest, {variant: 'error'}); | |
const direction = hasNextPage && pages.currentPage > pages.previousPage ? 'next' : | |
hasPreviousPage && pages.currentPage < pages.previousPage ? 'previous' : null | |
const dispatch = useDispatch(); | |
const getAds = useCallback(() => { | |
if (isMountedRef.current) { | |
dispatch(fetchEdgeNodes(`${adsetId}/ads`, direction, limit)); | |
dispatch(fetchTags(accountId)); | |
} | |
}, [isMountedRef, pages, limit, dispatch, lastModified]); | |
const getContest = useCallback(() => { | |
if (isMountedRef.current) { | |
dispatch(fetchContest(adsetId)); | |
} | |
}, [isMountedRef, dispatch, lastModified]); | |
useEffect(() => getAds(), [getAds]); | |
useEffect(() => getContest(), [getContest]); | |
const handlePageChange = (event, newPage) => { | |
setPages({previousPage: pages.currentPage, currentPage: newPage}); | |
} | |
const handleLimitChange = (event) => { | |
setLimit(parseInt(event.target.value)); | |
setPages({previousPage: 0, currentPage: 0}); | |
setSelected([]); | |
}; | |
const handleQueryChange = (event) => { | |
event.persist(); | |
setQuery(event.target.value); | |
}; | |
const queryFilter = useCallback((products) => products.filter((product) => isEmpty(query) || | |
product.name.includes(query)), [query]); | |
const filterSelectables = useCallback(() => { | |
return queryFilter(currentEdgeNodes).reduce((acc, row) => { | |
if (row.status === 'ACTIVE') return acc; | |
if ((row?.adlabels || []).some(({ | |
id, | |
name | |
}) => name === `${adsetId}:paused` || rContestLabels.test(name))) return acc; | |
const label = (row?.adlabels || []).filter(({ | |
id, | |
name | |
}) => /^tag:.+:\d+$/.test(name)).sort(descendingTagComparator)[0]; | |
return isEmpty(label) || label.id === tag ? [...acc, row] : acc; | |
}, []) | |
}, [currentEdgeNodes, queryFilter, tag]); | |
const resetSelected = (selectables) => { | |
const selected = selectables.reduce((acc, row) => (row?.adlabels || []).find(({id}) => id === tag) ? [...acc, row.id] : acc, []); | |
setSelectables(selectables); | |
setInitialSelected(selected) | |
setSelected(selected); | |
} | |
useEffect(() => { | |
resetSelected(filterSelectables()); | |
if (tag === "none") { | |
setLabelEnabled(false); | |
} else { | |
setLabelEnabled(true); | |
} | |
}, [filterSelectables]); | |
const handleSelectAllClick = (event) => event.target.checked ? setSelected(selectables.map(row => row.id)) : setSelected([]); | |
const handleToggle = (event, row) => { | |
if (tag === "none") return; | |
if (isTagged(row) && !isSelected(row.id)) return; | |
const selectedIndex = selected.indexOf(row.id); | |
let newSelected = []; | |
if (selectedIndex === -1) { | |
newSelected = newSelected.concat(selected, row.id); | |
} else if (selectedIndex === 0) { | |
newSelected = newSelected.concat(selected.slice(1)); | |
} else if (selectedIndex === selected.length - 1) { | |
newSelected = newSelected.concat(selected.slice(0, -1)); | |
} else if (selectedIndex > 0) { | |
newSelected = newSelected.concat( | |
selected.slice(0, selectedIndex), | |
selected.slice(selectedIndex + 1), | |
); | |
} | |
setSelected(newSelected); | |
} | |
const isSelected = (id) => selected.indexOf(id) !== -1; | |
const rows = queryFilter(currentEdgeNodes); | |
const emptyRows = limit - rows.length; | |
const statusColor = (status) => status === 'ACTIVE' ? 'primary' : 'inherit'; | |
const renderAdLabel = (rows) => rows.map(row => { | |
const labels = ((row?.adlabels || []).reduce((acc, {id, name}) => (id === adlabel?.id) ? acc | NOMINATED : | |
(name === `${adsetId}:paused` || id === expiration?.id) ? acc | PAUSED : | |
(rContestLabels.test(name)) ? acc | UNKNOWN : acc | CANDIDATE, 0)); | |
return { | |
...row, | |
name: row.name, | |
color: statusColor(row.status), | |
contest: labels & NOMINATED && 'nominated' || labels & PAUSED && 'paused' || labels & UNKNOWN && 'unknown' || labels & CANDIDATE && 'candidate' | |
} | |
}); | |
const matchWords = (value) => words.some(word => value.indexOf(word) !== -1); | |
const isTagged = row => (row?.adlabels || []).some(({id, name}) => /^tag:.+:\d+$/.test(name) && id !== tag); | |
const tagName = row => { | |
const adlabel = (row?.adlabels || []).filter(({name}) => /^tag:.+:\d+$/.test(name)).sort(descendingTagComparator)[0]; | |
return adlabel && adlabel.id !== tag && adlabel.name || null | |
} | |
const changes = { | |
remove: initialSelected | |
.filter(x => !selected.includes(x)), | |
add: selected.filter(x => !initialSelected.includes(x)) | |
} | |
const disableButton = isEmpty(changes.remove) && isEmpty(changes.add); | |
const batchRequest = async (tag, changes) => { | |
const added = changes.add.length > 0 ? await axios.post(`${hostConfig.baseUrl}/api/${tag}/associate`, changes.add) : {}; | |
const removed = changes.remove.length > 0 ? await axios.post(`${hostConfig.baseUrl}/api/${tag}/disassociate`, changes.remove) : {}; | |
return {added, removed} | |
} | |
const createItems = (tags) => { | |
const items = tags.filter(validTags); | |
return isEmpty(items) ? <MenuItem key="tagId-none" value="none"> | |
<Typography variant='inherit'><SvgIcon | |
fontSize='inherit' | |
color='action' | |
> | |
<LocalOffer/> | |
</SvgIcon></Typography> | |
</MenuItem> : [<MenuItem key="tagId-none" value="none"> | |
<Typography variant='inherit'><SvgIcon | |
fontSize='inherit' | |
color='action' | |
> | |
<LocalOffer/> | |
</SvgIcon> <em>未選択</em></Typography> | |
</MenuItem>, items.map(tag => <MenuItem key={tag.id} value={tag.id}><Typography | |
variant='inherit'><SvgIcon | |
fontSize='inherit' | |
color='primary' | |
> | |
<LocalOffer/> | |
</SvgIcon> {tag.tagName} {tag.tagDate !== '+999999999-12-31' && `(${moment(tag.tagDate).format('ll')})`} | |
</Typography></MenuItem>)] | |
} | |
return ( | |
<Container className={className}> | |
<Formik | |
initialValues={{tagId: 'none'}} | |
onSubmit={async (values, actions) => { | |
actions.setSubmitting(true); | |
batchRequest(values.tagId, changes) | |
.then(() => { | |
actions.setSubmitting(false); | |
enqueueSnackbar('保存しました/更新しました', {variant: 'success'}); | |
setLastModified(Date.now()); | |
}).catch(error => enqueueSnackbar(error?.message || '未知のエラー', {variant: 'error'})); | |
}}> | |
{formik => <form onSubmit={formik.handleSubmit}> | |
<Grid container justify='center' spacing={1}> | |
<Grid item xs={6} className={className}> | |
<TextField | |
fullWidth | |
InputProps={{ | |
startAdornment: ( | |
<InputAdornment position='start'> | |
<SvgIcon | |
fontSize='small' | |
color='action' | |
> | |
<SearchIcon/> | |
</SvgIcon> | |
</InputAdornment> | |
) | |
}} | |
onChange={handleQueryChange} | |
placeholder='検索' | |
value={query} | |
variant='outlined' | |
/> | |
</Grid> | |
<Grid item xs={4}> | |
<TextField | |
variant="outlined" | |
id="tagId" | |
select | |
label="ラベル" | |
value={formik.values.tagId} | |
onChange={(event) => { | |
const value = event.target.value; | |
setTag(value); | |
formik.setFieldValue('tagId', value); | |
}} | |
fullWidth | |
disabled={tags.length === 0} | |
> | |
{createItems(tags)} | |
</TextField> | |
</Grid> | |
<Grid item xs={1} className={classes.root}> | |
<Divider orientation="vertical" className={classes.divider}/> | |
<Button type="submit" className={classes.icon} | |
aria-label="create label" color="primary" | |
disabled={disableButton} variant='outlined' | |
> | |
保存 | |
</Button> | |
<Button type="button" className={classes.icon} | |
onClick={() => resetSelected(selectables)} | |
aria-label="create label" color="primary" | |
disabled={disableButton} variant='outlined'> | |
戻す | |
</Button> | |
</Grid> | |
</Grid> | |
</form> | |
} | |
</Formik> | |
<PerfectScrollbar> | |
<Box> | |
<Table className={className} | |
aria-labelledby="tableTitle" | |
size="medium" | |
aria-label="table view controller"> | |
<TableHead> | |
<TableRow> | |
{labelEnabled && totalCount < limit && selectables.length > 0 ? | |
<TableCell padding="checkbox"> | |
<Checkbox | |
indeterminate={selected.length > 0 && selected.length < selectables.length} | |
checked={selectables.length > 0 && selected.length === selectables.length} | |
onChange={handleSelectAllClick} | |
icon={<LocalOfferOutlined/>} | |
checkedIcon={<LocalOffer/>} | |
indeterminateIcon={<LocalOfferOutlined/>} | |
inputProps={{'aria-label': 'select all ads'}} | |
/> | |
</TableCell> : | |
<TableCell padding="checkbox"> | |
<Checkbox | |
disabled={true} | |
indeterminate={false} | |
checked={true} | |
icon={<LocalOffer/>} | |
checkedIcon={<LocalOffer/>} | |
indeterminateIcon={<LocalOfferOutlined/>} | |
inputProps={{'aria-label': 'select all ads'}} | |
/> | |
</TableCell>} | |
{/*<TableCell style={{width: 40}} padding="none">ON/OFF</TableCell>*/} | |
{headCells.map(headCell => | |
<TableCell key={headCell.id} style={{width: headCell.width}} | |
sortDirection={orderBy === headCell.id ? order : false} align='left'> | |
<TableSortLabel | |
active={orderBy === headCell.id} | |
direction={orderBy === headCell.id ? order : 'asc'} | |
onClick={createSortHandler(headCell.id)} | |
> | |
{headCell.label} | |
</TableSortLabel> | |
</TableCell> | |
)} | |
</TableRow> | |
</TableHead> | |
<TableBody> | |
{!edgeNodesIsLoading && stableSort(renderAdLabel(rows), getComparator(order, orderBy)) | |
.map(row => { | |
const tag = tagName(row); | |
const selected = isSelected(row.id); | |
return <Row key={row.id} | |
accountId={accountId} campaignId={campaignId} adsetId={adsetId} | |
ad={row} | |
tagName={tag} | |
disabled={!labelEnabled || tag && !selected || row.status === 'ACTIVE' || row.contest === 'paused' || row.contest === 'unknown'} | |
selected={labelEnabled && selected} | |
matchWords={matchWords} | |
onClick={(event) => handleToggle(event, row)} | |
enqueueSnackbar={enqueueSnackbar} | |
setLastModified={(date) => !setLastModified(date) && !reset() && start()} | |
/> | |
})} | |
{emptyRows > 0 && <TableRow style={{height: 53 * emptyRows}}> | |
<TableCell colSpan={4}/> | |
</TableRow>} | |
</TableBody> | |
</Table> | |
</Box> | |
</PerfectScrollbar> | |
<TablePagination | |
component="div" | |
count={totalCount} | |
onChangePage={handlePageChange} | |
onChangeRowsPerPage={handleLimitChange} | |
page={pages.currentPage} | |
rowsPerPage={limit} | |
rowsPerPageOptions={[10, 25, 50]} | |
/> | |
</Container> | |
); | |
}; | |
Results.propTypes = { | |
className: PropTypes.string, | |
adsetId: PropTypes.string.isRequired, | |
adlabel: PropTypes.object, | |
expiration: PropTypes.object, | |
words: PropTypes.array, | |
}; | |
export default Results; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment