Skip to content

Commit

Permalink
Update roster page to support filtering
Browse files Browse the repository at this point in the history
  • Loading branch information
AnthonyWhitaker authored Nov 26, 2020
1 parent 91e5bf1 commit 228e67c
Show file tree
Hide file tree
Showing 12 changed files with 899 additions and 66 deletions.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"express": "^4.17.1",
"express-async-errors": "^3.1.1",
"express-session": "^1.17.1",
"fast-deep-equal": "^3.1.3",
"http-proxy-middleware": "^0.20.0",
"json-2-csv": "^3.7.8",
"lodash": "^4.17.20",
Expand Down
9 changes: 9 additions & 0 deletions server/api/roster/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,13 +76,22 @@ router.get(
requireRolePermission(role => role.canManageRoster),
controller.getRosterInfo,
);

router.get(
'/:orgId',
requireOrgAccess,
requireRolePermission(role => role.canManageRoster),
controller.getRoster,
);

router.post(
'/:orgId/search',
requireOrgAccess,
requireRolePermission(role => role.canManageRoster),
bodyParser.json(),
controller.searchRoster,
);

router.post(
'/:orgId',
requireOrgAccess,
Expand Down
139 changes: 119 additions & 20 deletions server/api/roster/roster.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,20 @@ import { Role } from '../role/role.model';
import { Unit } from '../unit/unit.model';

class RosterController {
static getColumnSelect(column: RosterColumnInfo) {
// Make sure custom columns are converted to appropriate types
if (column.custom) {
switch (column.type) {
case RosterColumnType.Boolean:
return `(roster.custom_columns ->> '${column.name}')::BOOLEAN`;
case RosterColumnType.Number:
return `(roster.custom_columns ->> '${column.name}')::DOUBLE PRECISION`;
default:
return `roster.custom_columns ->> '${column.name}'`;
}
}
return `roster.${snakeCase(column.name)}`;
}

async addCustomColumn(req: ApiRequest<OrgParam, CustomColumnData>, res: Response) {
if (!req.body.name) {
Expand Down Expand Up @@ -180,6 +194,94 @@ class RosterController {
});
}

async searchRoster(req: ApiRequest<OrgParam, SearchRosterBody, GetRosterQuery>, res: Response) {
const limit = (req.query.limit != null) ? parseInt(req.query.limit) : 100;
const page = (req.query.page != null) ? parseInt(req.query.page) : 0;
const rosterColumns = await getAllowedRosterColumns(req.appOrg!, req.appRole!);

const columns: RosterColumnInfo[] = [{
name: 'unit',
displayName: 'Unit',
custom: false,
phi: false,
pii: false,
type: RosterColumnType.String,
updatable: false,
required: false,
}, ...rosterColumns];

async function makeQueryBuilder() {
return Object.keys(req.body)
.reduce((queryBuilder, key) => {
const column = columns.find(col => col.name === key);
if (!column) {
throw new BadRequestError('Malformed search query. Unexpected column name.');
}

const { op, value } = req.body[key];
const needsQuotedValue = column.type === RosterColumnType.Date || column.type === RosterColumnType.DateTime;
const maybeQuote = (v: CustomColumnValue) => (v !== null && needsQuotedValue ? `'${v}'` : v);
const columnName = RosterController.getColumnSelect(column);

if (op === 'between' || op === 'in') {
if (!Array.isArray(value)) {
throw new BadRequestError('Malformed search query. Expected array for value.');
}

if (op === 'in') {
return queryBuilder.andWhere(`${columnName} ${op} (:...${key})`, {
[key]: value,
});
}

if (op === 'between') {
return queryBuilder.andWhere(`${columnName} >= (:${key}Min) and ${columnName} <= (:${key}Max)`, {
[`${key}Min`]: maybeQuote(value[0]),
[`${key}Max`]: maybeQuote(value[1]),
});
}
}

if (Array.isArray(value)) {
throw new BadRequestError('Malformed search query. Expected scalar value.');
}

if (op === '=' || op === '<>' || op === '>' || op === '<') {
return queryBuilder.andWhere(`${columnName} ${op} :${key}`, {
[key]: maybeQuote(value),
});
}

if (op === '~' || op === 'startsWith' || op === 'endsWith') {
const prefix = op !== 'startsWith' ? '%' : '';
const suffix = op !== 'endsWith' ? '%' : '';
return queryBuilder.andWhere(`${columnName} like :${key}`, {
[key]: `${prefix}${value}${suffix}`,
});
}

throw new BadRequestError('Malformed search query. Received unexpected value for "op".');
}, await queryAllowedRoster(req.appOrg!, req.appRole!));
}

const queryBuilder = await makeQueryBuilder();

const roster = await queryBuilder.clone()
.skip(page * limit)
.take(limit)
.orderBy({
edipi: 'ASC',
})
.getRawMany<RosterEntryData>();

const totalRowsCount = await queryBuilder.getCount();

res.json({
rows: roster,
totalRowsCount,
});
}

async uploadRosterEntries(req: ApiRequest<OrgParam>, res: Response) {
const org = req.appOrg!;

Expand Down Expand Up @@ -393,31 +495,14 @@ async function queryAllowedRoster(org: Org, role: Role) {

// Add all columns that are allowed by the user's role
columns.forEach(column => {
if (column.custom) {
// Make sure custom columns are converted to appropriate types
let selection: string;
switch (column.type) {
case RosterColumnType.Boolean:
selection = `(roster.custom_columns ->> '${column.name}')::BOOLEAN`;
break;
case RosterColumnType.Number:
selection = `(roster.custom_columns ->> '${column.name}')::DOUBLE PRECISION`;
break;
default:
selection = `roster.custom_columns ->> '${column.name}'`;
break;
}
queryBuilder.addSelect(selection, column.name);
} else {
queryBuilder.addSelect(`roster.${snakeCase(column.name)}`, column.name);
}
queryBuilder.addSelect(RosterController.getColumnSelect(column), column.name);
});

// Filter out roster entries that are not on the active roster or are not allowed by the role's index prefix.
return queryBuilder
.where('u.org_id = :orgId', { orgId: org.id })
.andWhere('(roster.end_date IS NULL OR roster.end_date > now())')
.andWhere('(roster.start_date IS NULL OR roster.start_date < now())')
.andWhere('(roster.end_date IS NULL OR roster.end_date >= CURRENT_DATE)')
.andWhere('(roster.start_date IS NULL OR roster.start_date <= CURRENT_DATE)')
.andWhere('u.id like :name', { name: role.indexPrefix.replace('*', '%') });
}

Expand Down Expand Up @@ -592,6 +677,20 @@ interface RosterInfo {
columns: RosterColumnInfo[],
}

type GetRosterQuery = {
limit: string
page: string
};

type QueryOp = '=' | '<>' | '~' | '>' | '<' | 'startsWith' | 'endsWith' | 'in' | 'between';

type SearchRosterBody = {
[column: string]: {
op: QueryOp
value: CustomColumnValue | CustomColumnValue[]
}
};

type ReportDateQuery = {
reportDate: string
};
Expand Down
2 changes: 2 additions & 0 deletions server/api/roster/roster.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,14 @@ export class Roster extends BaseEntity {
lastName!: string;

@Column({
type: 'date',
nullable: true,
default: () => 'null',
})
startDate?: Date;

@Column({
type: 'date',
nullable: true,
default: () => 'null',
})
Expand Down
27 changes: 27 additions & 0 deletions server/migration/1606327183104-StartAndEndDateColumnsType.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { MigrationInterface, QueryRunner } from "typeorm";

export class StartAndEndDateColumnsType1606327183104 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "roster" ADD "start_date_value" DATE DEFAULT null`);
await queryRunner.query(`UPDATE "roster" SET "start_date_value" = DATE(start_date)`);
await queryRunner.query(`ALTER TABLE "roster" ALTER COLUMN start_date TYPE DATE USING start_date_value`);
await queryRunner.query(`ALTER TABLE "roster" DROP COLUMN "start_date_value"`);

await queryRunner.query(`ALTER TABLE "roster" ADD "end_date_value" DATE DEFAULT null`);
await queryRunner.query(`UPDATE "roster" SET "end_date_value" = DATE(end_date)`);
await queryRunner.query(`ALTER TABLE "roster" ALTER COLUMN end_date TYPE DATE USING end_date_value`);
await queryRunner.query(`ALTER TABLE "roster" DROP COLUMN "end_date_value"`);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "roster" ADD "start_date_value" timestamp without time zone DEFAULT null`);
await queryRunner.query(`UPDATE "roster" SET "start_date_value" = start_date::TIMESTAMP`);
await queryRunner.query(`ALTER TABLE "roster" ALTER COLUMN start_date TYPE timestamp without time zone USING start_date_value`);
await queryRunner.query(`ALTER TABLE "roster" DROP COLUMN "start_date_value"`);

await queryRunner.query(`ALTER TABLE "roster" ADD "end_date_value" timestamp without time zone DEFAULT null`);
await queryRunner.query(`UPDATE "roster" SET "end_date_value" = end_date::TIMESTAMP`);
await queryRunner.query(`ALTER TABLE "roster" ALTER COLUMN end_date TYPE timestamp without time zone USING end_date_value`);
await queryRunner.query(`ALTER TABLE "roster" DROP COLUMN "end_date_value"`);
}
}
33 changes: 33 additions & 0 deletions src/components/pages/roster-page/roster-page.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,37 @@ export default makeStyles((theme: Theme) => createStyles({
fillWidth: {
width: '100%',
},
secondaryButtons: {
marginLeft: theme.spacing(2),
marginTop: theme.spacing(2),
},
secondaryButton: {
borderWidth: 2,
marginRight: theme.spacing(2),

'&:hover': {
borderWidth: 2,
},
},
secondaryButtonCount: {
backgroundColor: '#005ea2',
borderRadius: '50%',
color: '#fff',
fontSize: '12px !important',
height: '18px',
minWidth: '18px',
},
tableWrapper: {
overflowY: 'auto',

'& tr > *': {
maxWidth: '220px',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
},
},
columnItem: {
paddingBottom: 0,
paddingTop: 0,
},
}));
Loading

0 comments on commit 228e67c

Please sign in to comment.