Skip to content

Commit

Permalink
chore: Add remaining changes for last commit
Browse files Browse the repository at this point in the history
  • Loading branch information
mwiraszka committed Dec 26, 2024
1 parent 5a203db commit 552777c
Show file tree
Hide file tree
Showing 35 changed files with 785 additions and 0 deletions.
8 changes: 8 additions & 0 deletions src/app/components/article-form/new-article-form-template.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type { ArticleFormData } from '@app/types';

export const newArticleFormTemplate: ArticleFormData = {
title: '',
body: '',
imageId: null,
isSticky: false,
};
15 changes: 15 additions & 0 deletions src/app/components/event-form/new-event-form-template.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import moment from 'moment-timezone';

import type { EventFormData } from '@app/types';

export const newEventFormTemplate: EventFormData = {
type: 'blitz tournament',
eventDate: moment()
.tz('America/Toronto', false)
.set('hours', 18)
.set('minutes', 0)
.toISOString(),
title: '',
details: '',
articleId: '',
};
18 changes: 18 additions & 0 deletions src/app/components/member-form/new-member-form-template.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import moment from 'moment-timezone';

import type { MemberFormData } from '@app/types';

export const newMemberFormTemplate: MemberFormData = {
firstName: '',
lastName: '',
city: 'London',
rating: '1000/0',
peakRating: '1000/0',
dateJoined: moment().toISOString(),
isActive: true,
chesscomUsername: '',
lichessUsername: '',
yearOfBirth: '',
email: '',
phoneNumber: '',
};
15 changes: 15 additions & 0 deletions src/app/pipes/camel-case.pipe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { camelCase } from 'lodash';

import { Pipe, PipeTransform } from '@angular/core';

/**
* Convert string to camel-case; return `''` if invalid string provided.
*/
@Pipe({
name: 'camelCase',
})
export class CamelCasePipe implements PipeTransform {
transform(value: unknown): string {
return typeof value === 'string' ? camelCase(value) : '';
}
}
9 changes: 9 additions & 0 deletions src/app/pipes/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export { CamelCasePipe } from './camel-case.pipe';
export { FormatBytesPipe } from './format-bytes.pipe';
export { FormatDatePipe } from './format-date.pipe';
export { KebabCasePipe } from './kebab-case.pipe';
export { RangePipe } from './range.pipe';
export { RouterLinkPipe } from './router-link.pipe';
export { StripMarkdownPipe } from './strip-markdown.pipe';
export { TruncateByCharsPipe } from './truncate-by-chars.pipe';
export { WasEditedPipe } from './was-edited.pipe';
15 changes: 15 additions & 0 deletions src/app/pipes/kebab-case.pipe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { kebabCase } from 'lodash';

import { Pipe, PipeTransform } from '@angular/core';

/**
* Convert string to kebab-case; return `''` if invalid string provided.
*/
@Pipe({
name: 'kebabCase',
})
export class KebabCasePipe implements PipeTransform {
transform(value: unknown): string {
return typeof value === 'string' ? kebabCase(value) : '';
}
}
16 changes: 16 additions & 0 deletions src/app/pipes/router-link.pipe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Pipe, PipeTransform } from '@angular/core';

import type { InternalPath } from '@app/types';

/**
* Parse potential InternalPath tuple and add '/' prefix for Angular's routerLink.
*/
@Pipe({
name: 'routerLink',
})
export class RouterLinkPipe implements PipeTransform {
transform(path: InternalPath | string): string {
const fullPath = Array.isArray(path) ? path.join('/') : path;
return '/' + fullPath;
}
}
36 changes: 36 additions & 0 deletions src/app/pipes/was-edited.pipe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import moment from 'moment-timezone';

import { Pipe, PipeTransform } from '@angular/core';

import type { ModificationInfo } from '@app/types';
import { isValidIsoDate } from '@app/utils';

/**
* Check whether dateLastEdited and dateCreated properties on a ModificationInfo object are the same
* to some level of granularity (defaults to `'day'`).
*
* Return `null` if modification info is `null` or `undefined`, or if either date is not a valid
* ISO 8601 date string.
*/
@Pipe({
name: 'wasEdited',
})
export class WasEditedPipe implements PipeTransform {
transform(
modificationInfo?: ModificationInfo | null,
granularity: moment.unitOfTime.StartOf = 'day',
): boolean | null {
if (
!modificationInfo ||
!isValidIsoDate(modificationInfo.dateCreated) ||
!isValidIsoDate(modificationInfo.dateLastEdited)
) {
return null;
}

return moment(modificationInfo.dateLastEdited).isSame(
modificationInfo.dateCreated,
granularity,
);
}
}
Empty file added src/app/type-guards/index.ts
Empty file.
22 changes: 22 additions & 0 deletions src/app/types/nav-path.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
const navPaths = [
'',
'about',
'members',
'member',
'schedule',
'event',
'news',
'article',
'city-champion',
'photo-gallery',
'game-archives',
'documents',
'login',
'logout',
'change-password',
] as const;
export type NavPath = (typeof navPaths)[number];

export function isNavPath(value: unknown): value is NavPath {
return navPaths.indexOf(value as NavPath) !== -1;
}
41 changes: 41 additions & 0 deletions src/app/utils/browser/is-storage-supported.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { isDefined } from '@app/utils';

/**
* Check whether a `localStorage` or `sessionStorage` is supported.
*
* Browsers can make the storage not accessible in different ways, such as not exposing it at all
* on the global object, or throwing errors as soon as it's attempted to be accessed. To account
* for all these cases, try to store a dummy item using a try & catch to analyze the thrown error.
*
* @param webStorageType The Web Storage API to check
*/
export function isStorageSupported(
webStorageType: 'localStorage' | 'sessionStorage' = 'localStorage',
): boolean {
let storage: Storage | undefined;

try {
storage = window[webStorageType];

if (!storage) {
return false;
}

const x = '__storage_test__';
storage.setItem(x, x);
storage.removeItem(x);

return true;
} catch (error) {
// Acknowledge a QuotaExceededError only if there's something already stored
const isValidQuotaExceededError =
isDefined(storage) &&
error instanceof DOMException &&
(error.code === 22 ||
error.code === 1014 ||
error.name === 'QuotaExceededError' ||
error.name === 'NS_ERROR_DOM_QUOTA_REACHED');

return isValidQuotaExceededError;
}
}
25 changes: 25 additions & 0 deletions src/app/utils/common/are-same.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { isEqual, mapValues } from 'lodash';

/**
* Check whether two objects are (deeply) equal.
*
* @param a The first object to compare
* @param b The second object to compare
* @param strict Treat null, undefined and empty string property values as unique
*/
export function areSame(a: unknown, b: unknown, strict: boolean = false): boolean {
if (a === null && b === null) {
return true;
}

if (a === null || b === null) {
return false;
}

if (!strict) {
a = mapValues(a, value => (value === undefined || value === '' ? null : value));
b = mapValues(b, value => (value === undefined || value === '' ? null : value));
}

return isEqual(a, b);
}
6 changes: 6 additions & 0 deletions src/app/utils/common/is-defined.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* Narrow down type of T by removing `null` and `undefined`.
*/
export function isDefined<T>(value: T & unknown): value is NonNullable<T> {
return value !== null && value !== undefined;
}
9 changes: 9 additions & 0 deletions src/app/utils/common/take-randomly.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/**
* Take N-number random items from an array if N defined; otherwise return the full array.
*
* @param array The array (any type)
* @param n The number of items to take
*/
export function takeRandomly<T>(array: T[], n?: number): T[] {
return array.sort(() => 0.5 - Math.random()).slice(0, n ?? array?.length ?? 0);
}
6 changes: 6 additions & 0 deletions src/app/utils/database/is-valid-collection-id.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* Check whether value is a valid MongoDB ID (used for identifying articles, events and members).
*/
export function isValidCollectionId(value: unknown): boolean {
return typeof value === 'string' && /^[a-f\d]{24}$/.test(value);
}
30 changes: 30 additions & 0 deletions src/app/utils/datetime/format-date.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import moment from 'moment-timezone';

import type { IsoDate } from '@app/types';

/**
* Convert ISO8601 date string (`YYYY-MM-DDTHH:mm:ss`) to one of the following formats:
* * `long`: Thursday, January 1st 2024 at 6:00 PM (default)
* * `long no-time`: Thursday, January 1st 2024
* * `short`: Thu, Jan 1, 2024, 6:00 PM
* * `short no-time`: Thu, Jan 1, 2024
* Timezone automatically displayed as EST/EDT due to default timezone set in root App Component.
*
* Return `'Invalid date'` if date provided is undefined or has invalid ISO860 format.
*/
export function formatDate(
date?: IsoDate,
format: 'long' | 'long no-time' | 'short' | 'short no-time' = 'long',
): string {
switch (format) {
case 'long':
return moment(date).format('dddd, MMMM Do YYYY [at] h:mm A');
case 'long no-time':
return moment(date).format('dddd, MMMM Do YYYY');
case 'short':
return moment(date).format('ddd, MMM D, YYYY, h:mm A');
case 'short no-time':
return moment(date).format('ddd, MMM D, YYYY');
}
}
8 changes: 8 additions & 0 deletions src/app/utils/datetime/is-valid-iso-date.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import moment from 'moment-timezone';

/**
* Check whether value is a valid ISO 8601 date string (i.e. in the form of `YYYY-MM-DDTHH:mm:ss`).
*/
export function isValidIsoDate(value: unknown): boolean {
return typeof value === 'string' && moment(value, moment.ISO_8601, true).isValid();
}
10 changes: 10 additions & 0 deletions src/app/utils/datetime/is-valid-time.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* Check whether value is a valid time, formatted as `hh:mm A`
* (e.g. `5:45 PM`, `6:00 am`, or `12:00 PM`).
*/
export function isValidTime(value: unknown): boolean {
return (
typeof value === 'string' &&
/^([1-9]|0[1-9]|1[0-2]):[0-5][0-9] ([AP][Mm]|[ap]m)$/.test(value)
);
}
18 changes: 18 additions & 0 deletions src/app/utils/error/parse-http-error-response.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { HttpErrorResponse } from '@angular/common/http';

/**
* Convert 0-status errors to standard 500-status server errors; otherwise return unchanged.
*
* @param {HttpErrorResponse} response
*/
export function parseHttpErrorResponse(response: HttpErrorResponse): HttpErrorResponse {
if (response.status !== 0) {
return response;
}

return {
...response,
status: 500,
error: 'Unknown server error.',
};
}
16 changes: 16 additions & 0 deletions src/app/utils/file/data-url-to-blob.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Url } from '@app/types';

/**
* Convert a data URL (base-64 string) of a File to a Blob representing the same data.
*/
export function dataUrlToBlob(dataUrl: Url): Blob {
const byteString = atob(dataUrl.split(',')[1]);
const arrayBuffer = new ArrayBuffer(byteString.length);
const int8Array = new Uint8Array(arrayBuffer);

for (let i = 0; i < byteString.length; i++) {
int8Array[i] = byteString.charCodeAt(i);
}

return new Blob([int8Array], { type: 'image/jpeg' });
}
18 changes: 18 additions & 0 deletions src/app/utils/file/format-bytes.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* Convert a raw number of Bytes to a more user-friendly size in `B` / `kB` / `MB` / `GB` units.
*/
export function formatBytes(value: unknown, decimals = 2): string {
const bytes = Number(value);

if (isNaN(bytes)) {
return '0 B';
}

const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['B', 'kB', 'MB', 'GB'];

const i = Math.floor(Math.log(bytes) / Math.log(k));

return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
}
34 changes: 34 additions & 0 deletions src/app/utils/file/parse-csv.util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* Parse a local CSV file and return an array of string arrays, where the inner arrays represent
* the rows in the original CSV file.
*
* Return `null` if for whatever reason the CSV cannot be parsed.
*/
export async function parseCsv(
filePath?: string,
skipFirstRow = true,
): Promise<Array<string[]> | null> {
if (!filePath || !filePath.endsWith('.csv')) {
return null;
}

const response = await fetch(filePath);
if (!response) {
return null;
}

const blob = await response.blob();
if (!blob) {
return null;
}

const arrayBuffer = await blob.text();
if (!arrayBuffer) {
return null;
}

let rowsOfData: Array<string[]> = [[]];
rowsOfData = arrayBuffer.split('\n')?.map(row => row.split(','));

return skipFirstRow && rowsOfData.length > 0 ? rowsOfData.slice(1) : rowsOfData;
}
Loading

0 comments on commit 552777c

Please sign in to comment.