Skip to content

Instantly share code, notes, and snippets.

@hanishi
Created September 8, 2021 01:02
Show Gist options
  • Save hanishi/db58f6fca78d420f8cb8d8d514855410 to your computer and use it in GitHub Desktop.
Save hanishi/db58f6fca78d420f8cb8d8d514855410 to your computer and use it in GitHub Desktop.
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