diff --git a/imports/_test/fixtures.ts b/imports/_test/fixtures.ts index 3288efa24..a5def8a6f 100644 --- a/imports/_test/fixtures.ts +++ b/imports/_test/fixtures.ts @@ -142,7 +142,6 @@ export const client = (title, fn) => { const prepare = async () => { // @ts-expect-error TODO await import('./mocha.css'); - await import('../../client/polyfill'); original = await forgetHistory(); await cleanup(); @@ -169,8 +168,6 @@ export const server = (title, fn) => { }; const prepare = async () => { - await import('../../server/polyfill'); - if (isAppTest()) { await appIsReady(); } diff --git a/imports/api/_dev/populate/consultations.ts b/imports/api/_dev/populate/consultations.ts index ea95c138e..94ed64a1b 100644 --- a/imports/api/_dev/populate/consultations.ts +++ b/imports/api/_dev/populate/consultations.ts @@ -17,6 +17,9 @@ export const newConsultationFormData = makeTemplate({ currency: () => 'EUR', price: () => faker.number.int(150), paid: () => 0, + payment_method: () => + faker.helpers.arrayElement(['cash', 'wire', 'third-party']), + book: () => `${faker.number.int(100)}`, }); diff --git a/imports/api/_dev/populate/uploads.ts b/imports/api/_dev/populate/uploads.ts index 4f8b43e4f..97c86e943 100644 --- a/imports/api/_dev/populate/uploads.ts +++ b/imports/api/_dev/populate/uploads.ts @@ -4,8 +4,8 @@ import {randomPNGBuffer, randomPNGDataURI} from '../../../_test/png'; import {type MetadataType, Uploads} from '../../uploads'; export const newUpload = async ( - invocation, - options?, + invocation?: {userId?: string}, + options?: {name?: string}, ): Promise<FileRef<MetadataType>> => { const type = 'image/png'; const fileName = options?.name ?? 'pic.png'; @@ -17,7 +17,7 @@ export const newUpload = async ( { fileName, type, - userId: invocation.userId, + userId: invocation?.userId, }, (writeError, fileRef) => { if (writeError) { diff --git a/imports/api/issues.ts b/imports/api/issues.ts index 5a8e7018f..2bc759321 100644 --- a/imports/api/issues.ts +++ b/imports/api/issues.ts @@ -1,36 +1,72 @@ -import {patientDocument, Patients} from './collection/patients'; -import {consultationDocument, Consultations} from './collection/consultations'; -import {documentDocument, Documents} from './collection/documents'; +import { + type PatientDocument, + patientDocument, + Patients, +} from './collection/patients'; +import { + type ConsultationDocument, + consultationDocument, + Consultations, +} from './collection/consultations'; +import { + type DocumentDocument, + documentDocument, + Documents, +} from './collection/documents'; +import { + type AttachmentDocument, + attachmentDocument, + Attachments, +} from './collection/attachments'; + import makeFilteredCollection from './makeFilteredCollection'; +import type Query from './query/Query'; +import { + type DoctorDocument, + doctorDocument, + Doctors, +} from './collection/doctors'; export const usePatientsMissingABirthdate = makeFilteredCollection( Patients, patientDocument, - { - $or: [{birthdate: null!}, {birthdate: ''}], - }, - undefined, + (ctx, {filter: userFilter, ...userOptions}): Query<PatientDocument> => ({ + filter: { + $or: [{birthdate: null}, {birthdate: ''}], + ...userFilter, + owner: ctx.userId, + }, + ...userOptions, + }), 'issues.PatientsMissingABirthdate', ); export const usePatientsMissingAGender = makeFilteredCollection( Patients, patientDocument, - { - $or: [{sex: null!}, {sex: ''}], - }, - undefined, + (ctx, {filter: userFilter, ...userOptions}): Query<PatientDocument> => ({ + filter: { + $or: [{sex: null}, {sex: ''}], + ...userFilter, + owner: ctx.userId, + }, + ...userOptions, + }), 'issues.PatientsMissingAGender', ); export const useConsultationsMissingABook = makeFilteredCollection( Consultations, consultationDocument, - { - isDone: true, - $or: [{book: null!}, {book: ''}], - }, - undefined, + (ctx, {filter: userFilter, ...userOptions}): Query<ConsultationDocument> => ({ + filter: { + isDone: true, + $or: [{book: null}, {book: ''}], + ...userFilter, + owner: ctx.userId, + }, + ...userOptions, + }), 'issues.ConsultationsMissingABook', ); @@ -38,81 +74,135 @@ export const useConsultationsWithPriceZeroNotInBookZero = makeFilteredCollection( Consultations, consultationDocument, - { - isDone: true, - price: 0, - book: {$ne: '0'}, - }, - undefined, + ( + ctx, + {filter: userFilter, ...userOptions}, + ): Query<ConsultationDocument> => ({ + filter: { + isDone: true, + price: 0, + book: {$ne: '0'}, + ...userFilter, + owner: ctx.userId, + }, + ...userOptions, + }), 'issues.ConsultationsWithPriceZeroNotInBookZero', ); export const useConsultationsMissingPaymentData = makeFilteredCollection( Consultations, consultationDocument, - { - isDone: true, - datetime: {$gte: new Date(2020, 0, 1)}, - $or: [ - /** - * We filter a superset of the following query: - * { - * $or: [{price: {$not: {$type: 1}}}, {price: Number.NaN}] - * }. - * - * Executing this query through meteor raises the following error: - * "The Mongo server and the Meteor query disagree on how many documents match your query." - * See https://forums.meteor.com/t/the-mongo-server-and-the-meteor-query-disagree-not-type/55086. - */ - {price: {$not: {$gt: 1}}}, - {paid: {$not: {$gt: 1}}}, - /** - * The original query must then be run on the superset loaded in minimongo. - * - * Remark: - * -- - * Note that: - * - true >= 1 - * - '' >= 0 - * - * So we cannot use price: {$not: {$gte: 0}} which would correspond to - * a supserset with negative prices included (and excluding prices in - * [0, 1)). - */ - {currency: {$not: {$type: 2}}}, - {payment_method: {$not: {$type: 2}}}, - ], - }, - undefined, + (ctx, {filter: userFilter, ...userOptions}): Query<ConsultationDocument> => ({ + filter: { + isDone: true, + datetime: {$gte: new Date(2020, 0, 1)}, + $or: [ + /** + * We filter a superset of the following query: + * { + * $or: [{price: {$not: {$type: 1}}}, {price: Number.NaN}] + * }. + * + * Executing this query through meteor raises the following error: + * "The Mongo server and the Meteor query disagree on how many documents match your query." + * See https://forums.meteor.com/t/the-mongo-server-and-the-meteor-query-disagree-not-type/55086. + */ + {price: {$not: {$gt: 1}}}, + {paid: {$not: {$gt: 1}}}, + /** + * The original query must then be run on the superset loaded in minimongo. + * + * Remark: + * -- + * Note that: + * - true >= 1 + * - '' >= 0 + * + * So we cannot use price: {$not: {$gte: 0}} which would correspond to + * a supserset with negative prices included (and excluding prices in + * [0, 1)). + */ + {currency: {$not: {$type: 2}}}, + {payment_method: {$not: {$type: 2}}}, + ], + ...userFilter, + owner: ctx.userId, + }, + ...userOptions, + }), 'issues.ConsultationsMissingPaymentData', ); export const useUnlinkedDocuments = makeFilteredCollection( Documents, documentDocument, - { - patientId: null!, - }, - undefined, + (ctx, {filter: userFilter, ...userOptions}): Query<DocumentDocument> => ({ + filter: { + patientId: null, + ...userFilter, + owner: ctx.userId, + }, + ...userOptions, + }), 'issues.UnlinkedDocuments', ); export const useMangledDocuments = makeFilteredCollection( Documents, documentDocument, - { - encoding: null!, - }, - undefined, + (ctx, {filter: userFilter, ...userOptions}): Query<DocumentDocument> => ({ + filter: { + encoding: null, + ...userFilter, + owner: ctx.userId, + }, + ...userOptions, + }), 'issues.MangledDocuments', ); export const useUnparsedDocuments = makeFilteredCollection( Documents, documentDocument, - { - parsed: false, - }, - undefined, + (ctx, {filter: userFilter, ...userOptions}): Query<DocumentDocument> => ({ + filter: { + parsed: false, + ...userFilter, + owner: ctx.userId, + }, + ...userOptions, + }), 'issues.UnparsedDocuments', ); + +export const useUnattachedUploads = makeFilteredCollection( + Attachments, + attachmentDocument, + (ctx, {filter: userFilter, ...userOptions}): Query<AttachmentDocument> => ({ + filter: { + $and: [ + {'meta.attachedToPatients': {$not: {$gt: ''}}}, + {'meta.attachedToConsultations': {$not: {$gt: ''}}}, + ], + ...userFilter, + userId: ctx.userId, + }, + ...userOptions, + }), + 'issues.UnattachedUploads', +); + +export const useDoctorsWithNonAlphabeticalSymbols = makeFilteredCollection( + Doctors, + doctorDocument, + (ctx, {filter: userFilter, ...userOptions}): Query<DoctorDocument> => ({ + filter: { + containsNonAlphabetical: true, + ...userFilter, + owner: ctx.userId, + }, + ...userOptions, + }), + 'issues.DoctorsWithNonAlphabeticalSymbols', +); diff --git a/imports/api/makeDebouncedResultsQuery.ts b/imports/api/makeDebouncedResultsQuery.ts index ece754075..6013418de 100644 --- a/imports/api/makeDebouncedResultsQuery.ts +++ b/imports/api/makeDebouncedResultsQuery.ts @@ -14,17 +14,18 @@ const makeDebouncedResultsQuery = collection: Collection<T, U>, publication: PublicationEndpoint<[UserQuery<T>]>, ) => - (query: UserQuery<T>, deps: DependencyList) => { + (query: UserQuery<T> | null, deps: DependencyList) => { const lastValue = useRef<U[]>(init); const {loading, results: currentValue} = useQuery( publication, [query], () => { - const [selector, options] = queryToSelectorOptionsPair(query); + const [selector, options] = queryToSelectorOptionsPair(query!); return collection.find(selector, options); }, deps, + query !== null, ); useEffect(() => { diff --git a/imports/api/makeFilteredCollection.ts b/imports/api/makeFilteredCollection.ts index 5c8fc2f52..8ed30d695 100644 --- a/imports/api/makeFilteredCollection.ts +++ b/imports/api/makeFilteredCollection.ts @@ -3,20 +3,53 @@ import {type DependencyList} from 'react'; import schema from '../util/schema'; import type Collection from './Collection'; -import type Selector from './query/Selector'; -import {options} from './query/Options'; -import type Options from './query/Options'; +import type Document from './Document'; import defineCollection from './collection/define'; import define from './publication/define'; import useCursor from './publication/useCursor'; import useSubscription from './publication/useSubscription'; import {AuthenticationLoggedIn} from './Authentication'; -import {userFilter} from './query/UserFilter'; -import type UserFilter from './query/UserFilter'; import observeSetChanges from './query/observeSetChanges'; -import type Filter from './query/Filter'; import {publishCursorObserver} from './publication/publishCursors'; +import type UserQuery from './query/UserQuery'; +import queryToSelectorOptionsPair from './query/queryToSelectorOptionsPair'; +import {type AuthenticatedContext} from './publication/Context'; +import type Query from './query/Query'; +import {userQuery} from './query/UserQuery'; + +type QueryArg<T> = ( + ctx: AuthenticatedContext, + publicationQuery: UserQuery<T>, +) => Query<T>; + +const makeFilteredCollectionPublication = <T extends Document, U>( + collection: Collection<T, U>, + query: QueryArg<T>, + name: string, +) => { + return async function (publicationQuery: UserQuery<T>) { + const {filter, ...rest} = query(this, publicationQuery); + + const options = { + ...rest, + skip: 0, + limit: 0, + }; + + const handle = await observeSetChanges( + collection, + filter, + options, + publishCursorObserver(this, name), + ); + + this.onStop(async (error?: Error) => { + await handle.emit('stop', error); + }); + this.ready(); + }; +}; const makeFilteredCollection = < S extends schema.ZodTypeAny, @@ -24,59 +57,32 @@ const makeFilteredCollection = < >( collection: Collection<schema.infer<S>, U>, tSchema: S, - filterSelector: Selector<schema.infer<S>> | undefined, - filterOptions: Options<schema.infer<S>> | undefined, + query: QueryArg<schema.infer<S>>, name: string, ) => { const publication = define({ name, authentication: AuthenticationLoggedIn, - schema: schema.tuple([ - userFilter(tSchema).nullable(), - options(tSchema).nullable(), - ]), - async handle( - publicationFilter: UserFilter<schema.infer<S>> | null, - publicationOptions: Options<schema.infer<S>> | null, - ) { - const scopedFilter = { - ...filterSelector, - ...publicationFilter, - owner: this.userId, - } as Filter<schema.infer<S>>; - - const options = { - ...filterOptions, - ...publicationOptions, - skip: 0, - limit: 0, - }; - - const handle = await observeSetChanges( - collection, - scopedFilter, - options, - publishCursorObserver(this, name), - ); - - this.onStop(async (error?: Error) => { - await handle.emit('stop', error); - }); - this.ready(); - }, + schema: schema.tuple([userQuery(tSchema)]), + handle: makeFilteredCollectionPublication(collection, query, name), }); const Filtered = defineCollection<schema.infer<S>, U>(name); - return ( - hookSelector: Selector<schema.infer<S>> = {}, - options: Options<schema.infer<S>> | undefined = undefined, - deps: DependencyList = [], - ) => { - const isLoading = useSubscription(publication, [null, options ?? null]); + return (query: UserQuery<schema.infer<S>> | null, deps: DependencyList) => { + const isLoading = useSubscription( + publication, + [{...query, filter: {}}], + query !== null, + ); const loadingSubscription = isLoading(); const {loading: loadingResults, results} = useCursor( - () => Filtered.find(hookSelector, options), + query === null + ? () => null + : () => { + const [hookSelector, options] = queryToSelectorOptionsPair(query); + return Filtered.find(hookSelector, options); + }, deps, ); const loading = loadingSubscription || loadingResults; diff --git a/imports/api/publication/patient/noShows.ts b/imports/api/publication/patient/noShows.ts index 8b53b190f..292c0974b 100644 --- a/imports/api/publication/patient/noShows.ts +++ b/imports/api/publication/patient/noShows.ts @@ -21,7 +21,7 @@ export default define({ owner: this.userId, patientId, isDone: false, - isCancelled: {$in: [false, null!]}, + isCancelled: {$in: [false, null]}, scheduledDatetime: {$lt: startOfToday()}, // TODO make reactive? }; const options = {fields: {_id: 1}}; diff --git a/imports/api/query/Filter.tests.ts b/imports/api/query/Filter.tests.ts new file mode 100644 index 000000000..6ace3a8fa --- /dev/null +++ b/imports/api/query/Filter.tests.ts @@ -0,0 +1,46 @@ +import {assert} from 'chai'; + +import schema from '../../util/schema'; +import {isomorphic} from '../../_test/fixtures'; + +import type ServerFilter from './Filter'; +import {filter as serverFilter} from './Filter'; + +const _asserInvalidInput = <T>(result: schema.SafeParseReturnType<T, T>) => { + assert.isFalse(result.success); + assert.lengthOf(result.error.issues, 1); + assert.strictEqual(result.error.issues[0]!.message, 'Invalid input'); +}; + +isomorphic(__filename, () => { + it('should work with $where (string)', () => { + const filter: ServerFilter<{name: string}> = { + $where: 'function() { return this.name !== undefined; }', + }; + const tSchema: schema.ZodType<typeof filter> = serverFilter( + schema.object({name: schema.string()}), + ); + assert.deepEqual(tSchema.parse(filter), filter); + }); + + it('should work with $where (function)', () => { + const filter: ServerFilter<{name: string}> = { + $where() { + return this.name !== ''; + }, + }; + const tSchema: schema.ZodType<typeof filter> = serverFilter( + schema.object({name: schema.string()}), + ); + assert.deepEqual(tSchema.parse(filter), filter); + }); + + it('should throw an error with $where', () => { + // @ts-expect-error -- NOTE: This is on purpose for testing. + const filter: ServerFilter<{name: string}> = {$where: true}; + const tSchema: schema.ZodType<typeof filter> = serverFilter( + schema.object({name: schema.string()}), + ); + _asserInvalidInput(tSchema.safeParse(filter)); + }); +}); diff --git a/imports/api/query/Filter.ts b/imports/api/query/Filter.ts index ae667bf87..71d53600e 100644 --- a/imports/api/query/Filter.ts +++ b/imports/api/query/Filter.ts @@ -1,6 +1,16 @@ -import {type Condition} from './UserFilter'; +import {chain} from '@iterable-iterator/chain'; +import {map} from '@iterable-iterator/map'; + +import schema from '../../util/schema'; + +import {$expr, $text, condition, type Condition} from './UserFilter'; import type WithId from './WithId'; -import {type FieldSpecifiers, type PropertyType} from './dotNotation'; +import { + fieldSpecifiers, + type FieldSpecifiers, + propertyType, + type PropertyType, +} from './dotNotation'; type RootFilterOperators<TSchema> = { $expr?: Record<string, any>; @@ -13,9 +23,11 @@ type RootFilterOperators<TSchema> = { $caseSensitive?: boolean; $diacriticSensitive?: boolean; }; - $where?: string | ((this: TSchema) => boolean); + $where?: string | WhereFunction<TSchema>; }; +type WhereFunction<TSchema> = (this: TSchema) => boolean; + type StrictFilter<TSchema> = { [P in FieldSpecifiers<WithId<TSchema>>]?: Condition< PropertyType<WithId<TSchema>, P> @@ -23,3 +35,40 @@ type StrictFilter<TSchema> = { } & RootFilterOperators<WithId<TSchema>>; export default StrictFilter; + +export const $where = schema.union([ + schema.string(), + schema.instanceof(Function), +]); + +export const filter = <S extends schema.ZodTypeAny>( + tSchema: S, +): schema.ZodType<StrictFilter<schema.infer<S>>> => { + const as = schema.lazy(() => schema.array(s)); + const s = schema + .object( + Object.fromEntries( + chain( + map( + (key: string) => [ + key, + condition(propertyType(tSchema, key) as any), + ], + fieldSpecifiers(tSchema), + ), + [ + ['$and', as], + ['$nor', as], + ['$or', as], + ['$expr', $expr(tSchema)], + ['$text', $text], + ['$where', $where], + ], + ), + ), + ) + .strict() + .partial(); + + return s; +}; diff --git a/imports/api/query/Options.ts b/imports/api/query/Options.ts index 837085eba..076fe01d2 100644 --- a/imports/api/query/Options.ts +++ b/imports/api/query/Options.ts @@ -11,7 +11,7 @@ import sort from './sort'; type Options<T> = Pick<Mongo.Options<T>, 'fields' | 'sort' | 'skip' | 'limit'>; /** - * @deprecated Use userFilter instead. + * @deprecated Use userQuery instead. */ export const options = <S extends schema.ZodTypeAny>( tSchema: S, diff --git a/imports/api/query/Query.ts b/imports/api/query/Query.ts index 2b371eceb..def2fef86 100644 --- a/imports/api/query/Query.ts +++ b/imports/api/query/Query.ts @@ -1,9 +1,30 @@ -import type Options from './Options'; -import type Selector from './Selector'; +import schema from '../../util/schema'; + +import sort, {type Sort} from './sort'; +import {projection} from './Projection'; +import type Projection from './Projection'; +import type Filter from './Filter'; +import {filter} from './Filter'; type Query<T> = { - selector: Selector<T>; - options: Options<T> | null; + filter: Filter<T>; + projection?: Projection<T>; + sort?: Sort<T>; + skip?: number; + limit?: number; }; +export const query = <S extends schema.ZodTypeAny>( + tSchema: S, +): schema.ZodType<Query<schema.infer<S>>> => + schema + .object({ + filter: filter(tSchema), + projection: projection(tSchema).optional(), + sort: sort(tSchema).optional(), + skip: schema.number().int().optional(), + limit: schema.number().int().optional(), + }) + .strict() as schema.ZodType<Query<schema.infer<S>>>; + export default Query; diff --git a/imports/api/query/UserFilter.tests.ts b/imports/api/query/UserFilter.tests.ts new file mode 100644 index 000000000..3bb90c750 --- /dev/null +++ b/imports/api/query/UserFilter.tests.ts @@ -0,0 +1,124 @@ +import {assert} from 'chai'; + +import schema from '../../util/schema'; +import {isomorphic} from '../../_test/fixtures'; + +import type UserFilter from './UserFilter'; +import {userFilter} from './UserFilter'; + +const _asserInvalidInput = <T>(result: schema.SafeParseReturnType<T, T>) => { + assert.isFalse(result.success); + assert.lengthOf(result.error.issues, 1); + assert.strictEqual(result.error.issues[0]!.message, 'Invalid input'); +}; + +isomorphic(__filename, () => { + it('should work on a simple schema', () => { + const filter: UserFilter<{name: string}> = {name: 'abcd'}; + const tSchema: schema.ZodType<typeof filter> = userFilter( + schema.object({name: schema.string()}), + ); + assert.deepEqual(tSchema.parse(filter), filter); + }); + + it('should work on a simple schema (NaN)', () => { + const filter: UserFilter<{count: number}> = {count: Number.NaN}; + const tSchema: schema.ZodType<typeof filter> = userFilter( + schema.object({count: schema.number()}), + ); + assert.deepEqual(tSchema.parse(filter), filter); + }); + + it('should work on a schema with an optional field', () => { + const filter: UserFilter<{name?: string}> = {name: null}; + const tSchema: schema.ZodType<typeof filter> = userFilter( + schema.object({name: schema.string().optional()}), + ); + assert.deepEqual(tSchema.parse(filter), filter); + }); + + it('should work on a partial object schema', () => { + const filter: UserFilter<Partial<{name: string}>> = {name: null}; + const tSchema: schema.ZodType<typeof filter> = userFilter( + schema.object({name: schema.string()}).partial(), + ); + assert.deepEqual(tSchema.parse(filter), filter); + }); + + it('should work on a schema containing an array (single value filter)', () => { + const filter: UserFilter<{name: string[]}> = {name: 'abcd'}; + const tSchema: schema.ZodType<typeof filter> = userFilter( + schema.object({name: schema.array(schema.string())}), + ); + assert.deepEqual(tSchema.parse(filter), filter); + }); + + it('should work on a schema containing an array (array filter)', () => { + const filter: UserFilter<{name: string[]}> = {name: ['abcd']}; + const tSchema: schema.ZodType<typeof filter> = userFilter( + schema.object({name: schema.array(schema.string())}), + ); + assert.deepEqual(tSchema.parse(filter), filter); + }); + + it('should work on a schema containing an array (array `$elemMatch` filter)', () => { + const filter: UserFilter<{relations: Array<{name: string}>}> = { + relations: {$elemMatch: {name: 'abcd'}}, + }; + const tSchema: schema.ZodType<typeof filter> = userFilter( + schema.object({ + relations: schema.array(schema.object({name: schema.string()})), + }), + ); + assert.deepEqual(tSchema.parse(filter), filter); + }); + + it('should throw error on a simple schema', () => { + // @ts-expect-error -- NOTE: This is on purpose for testing. + const filter: UserFilter<{name: string}> = {name: 1}; + const tSchema: schema.ZodType<typeof filter> = userFilter( + schema.object({name: schema.string()}), + ); + _asserInvalidInput(tSchema.safeParse(filter)); + }); + + it('should throw error on a simple schema (null)', () => { + // @ts-expect-error -- NOTE: This is on purpose for testing. + const filter: UserFilter<{name: string}> = {name: null}; + const tSchema: schema.ZodType<typeof filter> = userFilter( + schema.object({name: schema.string()}), + ); + _asserInvalidInput(tSchema.safeParse(filter)); + }); + + it('should throw an error on a schema containing an array (single value filter)', () => { + // @ts-expect-error -- NOTE: This is on purpose for testing. + const filter: UserFilter<{name: string[]}> = {name: 1}; + const tSchema: schema.ZodType<typeof filter> = userFilter( + schema.object({name: schema.array(schema.string())}), + ); + _asserInvalidInput(tSchema.safeParse(filter)); + }); + + it('should throw an error on a schema containing an array (array filter)', () => { + // @ts-expect-error -- NOTE: This is on purpose for testing. + const filter: UserFilter<{name: string[]}> = {name: [1]}; + const tSchema: schema.ZodType<typeof filter> = userFilter( + schema.object({name: schema.array(schema.string())}), + ); + _asserInvalidInput(tSchema.safeParse(filter)); + }); + + it('should throw an error on a schema containing an array (array `$elemMatch` filter)', () => { + const filter: UserFilter<{relations: Array<{name: string}>}> = { + // @ts-expect-error -- NOTE: This is on purpose for testing. + relations: {$elemMatch: {name: 1}}, + }; + const tSchema: schema.ZodType<typeof filter> = userFilter( + schema.object({ + relations: schema.array(schema.object({name: schema.string()})), + }), + ); + _asserInvalidInput(tSchema.safeParse(filter)); + }); +}); diff --git a/imports/api/query/UserFilter.ts b/imports/api/query/UserFilter.ts index ed23acefb..e11113cc2 100644 --- a/imports/api/query/UserFilter.ts +++ b/imports/api/query/UserFilter.ts @@ -37,14 +37,14 @@ type UserFilter<TSchema> = { >; } & RootFilterOperators<WithId<TSchema>>; -const $text = schema.object({ +export const $text = schema.object({ $search: schema.string(), $language: schema.string().optional(), $caseSensitive: schema.boolean().optional(), $diacriticSensitive: schema.boolean().optional(), }); -const $expr = <S extends schema.ZodTypeAny>( +export const $expr = <S extends schema.ZodTypeAny>( // @ts-expect-error TODO tSchema: S /* TODO */, ) => schema.record(schema.string(), schema.any()); @@ -86,11 +86,21 @@ export type Condition<T> = * string types can be searched using a regex in mongo * array types can be searched using their element type */ -type AlternativeType<T> = T extends ReadonlyArray<infer U> - ? T | Condition<U> - : RegExpOrString<T>; +type AlternativeType<T> = T extends undefined + ? null | T + : T extends Array<infer U> + ? ArrayFilter<U, T> + : T extends string + ? StringFilter<T> + : T extends number + ? NumberFilter<T> + : T; -type RegExpOrString<T> = T extends string ? BSONRegExp | RegExp | T : T; +type ArrayFilter<U, T extends readonly U[] = readonly U[]> = T | Condition<U>; + +type StringFilter<T extends string> = BSONRegExp | RegExp | T; + +type NumberFilter<T extends number> = T; export const condition = <S extends schema.ZodTypeAny>( tSchema: S, @@ -102,27 +112,43 @@ export const condition = <S extends schema.ZodTypeAny>( const alternativeType = <S extends schema.ZodTypeAny>( tSchema: S, ): schema.ZodType<AlternativeType<schema.infer<S>>> => { - tSchema = unwrap(tSchema); + const [_tSchema, wrap] = unwrap(tSchema); - if (tSchema instanceof schema.ZodArray) { - return schema.union([tSchema, condition(tSchema.element)]); + if (_tSchema instanceof schema.ZodArray) { + return wrap(arrayFilter(_tSchema)); + } + + if (_tSchema instanceof schema.ZodString) { + return wrap(stringFilter(_tSchema)); + } + + if (_tSchema instanceof schema.ZodNumber) { + return wrap(numberFilter(_tSchema)); } - return regExpOrString(tSchema) as any; + return wrap(_tSchema); }; -const regExpOrString = <S extends schema.ZodTypeAny>( +const arrayFilter = <U extends schema.ZodTypeAny, S extends schema.ZodArray<U>>( tSchema: S, -): schema.ZodType<RegExpOrString<schema.infer<S>>> => { - if (unwrap(tSchema) instanceof schema.ZodString) { - return schema.union([ - tSchema, - schema.instanceof(RegExp), - // schema.instanceof(BSONRegExp), // TODO This requires an explicit dependency on bson. - ]) as any; - } +): schema.ZodType<ArrayFilter<schema.infer<U>, schema.infer<S>>> => { + return schema.union([tSchema, condition(tSchema.element)]); +}; - return tSchema; +const stringFilter = <S extends schema.ZodString>( + tSchema: S, +): schema.ZodType<StringFilter<schema.infer<S>>> => { + return schema.union([ + tSchema, + schema.instanceof(RegExp), + // schema.instanceof(BSONRegExp), // TODO This requires an explicit dependency on bson. + ]); +}; + +const numberFilter = <S extends schema.ZodNumber>( + tSchema: S, +): schema.ZodType<NumberFilter<schema.infer<S>>> => { + return schema.union([tSchema, schema.nan()]); }; export type FilterOperators<TValue> = { @@ -187,7 +213,7 @@ export const filterOperators = <S extends schema.ZodTypeAny>( $nin: schema.array(tSchema.nullable()), // Logical $not: schema.lazy(() => - unwrap(tSchema) instanceof schema.ZodString + unwrap(tSchema)[0] instanceof schema.ZodString ? schema.union(s, schema.instanceof(RegExp)) : s, ), @@ -226,27 +252,69 @@ export const filterOperators = <S extends schema.ZodTypeAny>( return s; }; -const unwrap = <S extends schema.ZodTypeAny>(tSchema: S) => { - if ( - tSchema instanceof schema.ZodOptional || - tSchema instanceof schema.ZodBranded - ) { - return unwrap(tSchema.unwrap()); +const unwrap = <S extends schema.ZodTypeAny>(tSchema: S) => + _unwrap(tSchema, (x: S) => x); + +const _unwrap = <W extends schema.ZodTypeAny, S extends schema.ZodTypeAny = W>( + tSchema: S, + wrap: (tSchema: S) => W, +) => { + if (tSchema instanceof schema.ZodOptional) { + const [_tSchema, _wrap] = _unwrapOptional(tSchema); + return [_tSchema, (x: typeof _tSchema) => wrap(_wrap(x))] as const; + } + + if (tSchema instanceof schema.ZodNullable) { + const [_tSchema, _wrap] = _unwrapNullable(tSchema); + return [_tSchema, (x: typeof _tSchema) => wrap(_wrap(x))] as const; } - return tSchema; + if (tSchema instanceof schema.ZodBranded) { + const [_tSchema, _wrap] = _unwrapBranded(tSchema); + return [_tSchema, (x: typeof _tSchema) => wrap(_wrap(x))] as const; + } + + return [tSchema, wrap] as const; +}; + +const _unwrapOptional = < + S extends schema.ZodOptional<T>, + T extends schema.ZodTypeAny, +>( + tSchema: S, +) => { + return _unwrap(tSchema.unwrap(), (x) => x.nullable()); +}; + +const _unwrapNullable = < + S extends schema.ZodNullable<T>, + T extends schema.ZodTypeAny, +>( + tSchema: S, +) => { + return _unwrap(tSchema.unwrap(), (x) => x.nullable()); +}; + +const _unwrapBranded = < + S extends schema.ZodBranded<T, B>, + T extends schema.ZodTypeAny, + B extends string, +>( + tSchema: S, +) => { + return _unwrap(tSchema.unwrap(), (x) => x.brand<B>()); }; const operator = <O extends (s: schema.ZodTypeAny) => schema.ZodTypeAny>(op: O) => <S extends schema.ZodTypeAny>(tSchema: S) => { - tSchema = unwrap(tSchema); + const [_tSchema, wrap] = unwrap(tSchema); - if (tSchema instanceof schema.ZodUnion) { - return schema.union(tSchema.options.map(op)); + if (_tSchema instanceof schema.ZodUnion) { + return wrap(schema.union(_tSchema.options.map(op))); } - return op(tSchema); + return wrap(op(_tSchema)); }; const $all = operator(<S extends schema.ZodTypeAny>(tSchema: S) => { diff --git a/imports/ui/App.tests.tsx b/imports/ui/App.tests.tsx index 18b728632..b88b99a52 100644 --- a/imports/ui/App.tests.tsx +++ b/imports/ui/App.tests.tsx @@ -1,5 +1,7 @@ import React from 'react'; +import {type BoundFunctions, type queries} from '@testing-library/dom'; + import {render, waitForElementToBeRemoved} from '../_test/react'; import {client, randomPassword, randomUserId} from '../_test/fixtures'; @@ -10,7 +12,7 @@ const setup = async (jsx: JSX.Element) => { const {default: userEvent} = await import('@testing-library/user-event'); return { user: userEvent.setup(), - ...render(jsx), + ...(render(jsx) as BoundFunctions<typeof queries>), }; }; @@ -32,23 +34,17 @@ client(__filename, () => { }); it('should allow to register a new user', async () => { + const {createUserWithPasswordAndLogin} = await import( + '../../test/app/client/fixtures' + ); const username = randomUserId(); const password = randomPassword(); - const {getByRole, findByRole, getByLabelText, user} = await setupApp(); - await user.click(await findByRole('button', {name: 'Sign in'})); - await user.click(await findByRole('button', {name: 'Create account?'})); - await findByRole('button', {name: 'Register'}); - await user.type(getByLabelText('Username'), username); - await user.type(getByLabelText('Password'), password); - await user.click(getByRole('button', {name: 'Register'})); - await waitForElementToBeRemoved( - () => { - return getByRole('button', {name: 'Register'}); - }, - {timeout: 5000}, - ); - await user.click( - await findByRole('button', {name: `Logged in as ${username}`}), + const app = await setupApp(); + const button = await createUserWithPasswordAndLogin( + app, + username, + password, ); + await app.user.click(button); }); }); diff --git a/imports/ui/Routing.tsx b/imports/ui/Routing.tsx index 33bb89f59..185326a56 100644 --- a/imports/ui/Routing.tsx +++ b/imports/ui/Routing.tsx @@ -157,7 +157,7 @@ export default function Routing() { <Route element={<Stats />} path="stats" /> <Route element={<SEPAPaymentDetails />} path="sepa" /> - <Route element={<Issues />} path="issues" /> + <Route element={<Issues />} path="issues/*" /> <Route element={<MergePatientsForm />} path="merge" /> <Route element={<DoctorsListRoutes />} path="doctors/*" /> diff --git a/imports/ui/appointments/AppointmentsForPatient.tsx b/imports/ui/appointments/AppointmentsForPatient.tsx index ef9f723ba..2fe8751e2 100644 --- a/imports/ui/appointments/AppointmentsForPatient.tsx +++ b/imports/ui/appointments/AppointmentsForPatient.tsx @@ -30,7 +30,7 @@ const _filter = (patientId: string, {showCancelled, showNoShow}) => { $or: removeUndefinedValuesFromArray([ removeUndefinedValuesFromObject({ isCancelled: { - $in: [false, null!], + $in: [false, null], }, scheduledDatetime: showNoShow ? undefined diff --git a/imports/ui/attachments/AttachmentCard.tsx b/imports/ui/attachments/AttachmentCard.tsx index 1c1c0b083..8e3feb8bf 100644 --- a/imports/ui/attachments/AttachmentCard.tsx +++ b/imports/ui/attachments/AttachmentCard.tsx @@ -49,32 +49,43 @@ const classes = { thumbnail: `${PREFIX}-thumbnail`, }; -const StyledCard = styled(Card)(({theme}) => ({ - [`&.${classes.card}`]: { - display: 'block', - margin: theme.spacing(1), - }, - - [`& .${classes.headerContent}`]: { - overflow: 'hidden', - }, - - [`& .${classes.headerContentTitle}`]: { - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - }, - - [`& .${classes.headerContentSubheader}`]: { - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - }, - - [`& .${classes.thumbnail}`]: { - height: 300, - }, -})); +type AdditionalProps = { + loading: boolean; +}; + +const additionalProps = new Set<number | string | Symbol>(['loading']); +const shouldForwardProp = (prop: string | number | Symbol) => + !additionalProps.has(prop); +const StyledCard = styled(Card, {shouldForwardProp})<AdditionalProps>( + ({theme, loading}) => ({ + [`&.${classes.card}`]: { + display: 'block', + margin: theme.spacing(1), + transition: 'opacity 500ms ease-out', + opacity: loading ? 0.7 : 1, + }, + + [`& .${classes.headerContent}`]: { + overflow: 'hidden', + }, + + [`& .${classes.headerContentTitle}`]: { + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + + [`& .${classes.headerContentSubheader}`]: { + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + + [`& .${classes.thumbnail}`]: { + height: 300, + }, + }), +); const initialState = { menu: null, @@ -144,11 +155,12 @@ export type AttachmentInfo = { }; type Props = { + readonly loading?: boolean; readonly attachment: AttachmentDocument; readonly info?: AttachmentInfo; }; -const AttachmentCard = ({attachment, info}: Props) => { +const AttachmentCard = ({loading = false, attachment, info}: Props) => { const [state, dispatch] = useReducer(reducer, initialState); const {menu, editing, linking, deleting, superDeleting} = state; @@ -190,7 +202,7 @@ const AttachmentCard = ({attachment, info}: Props) => { const open = Boolean(menu); return ( - <StyledCard className={classes.card}> + <StyledCard className={classes.card} loading={loading}> <CardHeader classes={headerClasses} avatar={ @@ -319,6 +331,7 @@ const AttachmentCard = ({attachment, info}: Props) => { width={450} height={300} attachmentId={attachment._id} + title={attachment.name} /> </CardActionArea> </StyledCard> diff --git a/imports/ui/attachments/AttachmentsForPatientStatic.tsx b/imports/ui/attachments/AttachmentsForPatientStatic.tsx index d204c312b..8a9ebd52d 100644 --- a/imports/ui/attachments/AttachmentsForPatientStatic.tsx +++ b/imports/ui/attachments/AttachmentsForPatientStatic.tsx @@ -13,7 +13,6 @@ import AttachmentsForPatientPager from './AttachmentsForPatientPager'; type Props = { readonly patientId: string; - // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents } & PropsOf<typeof AttachmentsForPatientPager>; const AttachmentsForPatientStatic = ({patientId, ...rest}: Props) => { diff --git a/imports/ui/attachments/AttachmentsGrid.tsx b/imports/ui/attachments/AttachmentsGrid.tsx index 3ad365e8a..8dbfdbf3d 100644 --- a/imports/ui/attachments/AttachmentsGrid.tsx +++ b/imports/ui/attachments/AttachmentsGrid.tsx @@ -9,11 +9,13 @@ import {type AttachmentDocument} from '../../api/collection/attachments'; import AttachmentCard, {type AttachmentInfo} from './AttachmentCard'; type AttachmentsGridProps = { + readonly loading?: boolean; readonly attachments: AttachmentDocument[]; readonly attachmentsInfo?: Map<string, AttachmentInfo>; } & PropsOf<typeof Grid>; const AttachmentsGrid = ({ + loading, attachments, attachmentsInfo, ...rest @@ -22,6 +24,7 @@ const AttachmentsGrid = ({ {attachments.map((attachment) => ( <Grid key={attachment._id} item sm={12} md={4} xl={3}> <AttachmentCard + loading={loading} attachment={attachment} info={attachmentsInfo?.get(attachment._id)} /> diff --git a/imports/ui/attachments/PagedAttachmentsList.tsx b/imports/ui/attachments/PagedAttachmentsList.tsx new file mode 100644 index 000000000..6b7601e97 --- /dev/null +++ b/imports/ui/attachments/PagedAttachmentsList.tsx @@ -0,0 +1,54 @@ +import React from 'react'; + +import type PropsOf from '../../util/types/PropsOf'; + +import Loading from '../navigation/Loading'; +import Paginator from '../navigation/Paginator'; +import Refresh from '../navigation/Refresh'; +import NoContent from '../navigation/NoContent'; + +import {type AttachmentDocument} from '../../api/collection/attachments'; + +import AttachmentsGrid from './AttachmentsGrid'; + +type PagedAttachmentsListProps = { + readonly page: number; + readonly perpage: number; + readonly items: AttachmentDocument[]; + readonly loading?: boolean; + readonly dirty?: boolean; + readonly refresh?: () => void; + readonly LoadingIndicator?: React.ElementType<{}>; + readonly EmptyPage?: React.ElementType<{page: number}>; +} & Omit<PropsOf<typeof AttachmentsGrid>, 'attachments'>; + +const DefaultLoadingIndicator = Loading; +const DefaultEmptyPage = ({page}: {readonly page: number}) => ( + <NoContent>{`Nothing to see on page ${page}.`}</NoContent> +); + +const PagedAttachmentsList = ({ + loading = false, + page, + perpage, + items, + refresh = undefined, + dirty = false, + LoadingIndicator = DefaultLoadingIndicator, + EmptyPage = DefaultEmptyPage, + ...rest +}: PagedAttachmentsListProps) => ( + <div> + {loading && items.length === 0 ? ( + <LoadingIndicator /> + ) : items.length > 0 ? ( + <AttachmentsGrid loading={loading} attachments={items} {...rest} /> + ) : ( + <EmptyPage page={page} /> + )} + <Paginator loading={loading} end={items.length < perpage} /> + {refresh && dirty && <Refresh onClick={refresh} />} + </div> +); + +export default PagedAttachmentsList; diff --git a/imports/ui/attachments/makeAttachmentsPage.tsx b/imports/ui/attachments/makeAttachmentsPage.tsx new file mode 100644 index 000000000..e7e9f1090 --- /dev/null +++ b/imports/ui/attachments/makeAttachmentsPage.tsx @@ -0,0 +1,47 @@ +import React from 'react'; + +import type PropsOf from '../../util/types/PropsOf'; + +import {type Sort} from '../../api/query/sort'; +import type UserFilter from '../../api/query/UserFilter'; +import type GenericQueryHook from '../../api/GenericQueryHook'; + +import {type AttachmentDocument} from '../../api/collection/attachments'; + +import PagedAttachmentsList from './PagedAttachmentsList'; + +type Props = { + readonly url?: string; + readonly page?: number; + readonly perpage?: number; + + readonly filter?: UserFilter<AttachmentDocument>; + readonly sort: Sort<AttachmentDocument>; +} & Omit<PropsOf<typeof PagedAttachmentsList>, 'page' | 'perpage' | 'items'>; + +const makeAttachmentsPage = + (useUploads: GenericQueryHook<AttachmentDocument>) => + ({filter = {}, sort, page = 1, perpage = 10, url, ...rest}: Props) => { + const query = { + filter, + sort, + skip: (page - 1) * perpage, + limit: perpage, + }; + + const deps = [JSON.stringify(query)]; + + const {loading, results: items} = useUploads(query, deps); + + return ( + <PagedAttachmentsList + loading={loading} + page={page} + perpage={perpage} + items={items} + {...rest} + /> + ); + }; + +export default makeAttachmentsPage; diff --git a/imports/ui/cards/AnimatedCardMedia.tsx b/imports/ui/cards/AnimatedCardMedia.tsx index 47034f916..c091446b1 100644 --- a/imports/ui/cards/AnimatedCardMedia.tsx +++ b/imports/ui/cards/AnimatedCardMedia.tsx @@ -33,6 +33,7 @@ const AnimatedCardMedia = ({ loading, image, placeholder, + title, ...rest }: AnimatedCardMediaProps) => { const transition = useTransition(image, { @@ -51,6 +52,7 @@ const AnimatedCardMedia = ({ component={animated.div} className={classes.item} image={item} + title={style.opacity.goal === 1 ? title : undefined} style={style as unknown as React.CSSProperties} {...rest} /> diff --git a/imports/ui/consultations/ConsultationForm.tsx b/imports/ui/consultations/ConsultationForm.tsx index b17033ab3..9e18db52a 100644 --- a/imports/ui/consultations/ConsultationForm.tsx +++ b/imports/ui/consultations/ConsultationForm.tsx @@ -390,7 +390,7 @@ const _priceProps = ({ case PriceStatus.SHOULD_NOT_BE_ZERO: { return { color: 'warning', - helperText: 'Price is zero but book is not special', + helperText: 'Price is zero but book is not `0`', focused: true, }; } diff --git a/imports/ui/consultations/ConsultationsForPatient.tsx b/imports/ui/consultations/ConsultationsForPatient.tsx index b40d7c3f1..0a97b7acf 100644 --- a/imports/ui/consultations/ConsultationsForPatient.tsx +++ b/imports/ui/consultations/ConsultationsForPatient.tsx @@ -36,7 +36,7 @@ const ConsultationsForPatient = ({patientId}: Props) => { isDone: true, }; - const sort = {datetime: -1}; + const sort = {datetime: -1} as const; return ( <> diff --git a/imports/ui/consultations/ConsultationsPage.ts b/imports/ui/consultations/ConsultationsPage.ts new file mode 100644 index 000000000..e1af12281 --- /dev/null +++ b/imports/ui/consultations/ConsultationsPage.ts @@ -0,0 +1,9 @@ +import makeConsultationsPage from './makeConsultationsPage'; + +import useConsultationsAndAppointments from './useConsultationsAndAppointments'; + +const ConsultationsPage = makeConsultationsPage( + useConsultationsAndAppointments, +); + +export default ConsultationsPage; diff --git a/imports/ui/consultations/ConsultationsPage.tsx b/imports/ui/consultations/ConsultationsPage.tsx deleted file mode 100644 index 69169ae1e..000000000 --- a/imports/ui/consultations/ConsultationsPage.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import React from 'react'; - -import {type ConsultationDocument} from '../../api/collection/consultations'; -import type UserFilter from '../../api/query/UserFilter'; -import type PropsOf from '../../util/types/PropsOf'; - -import PagedConsultationsList from './PagedConsultationsList'; -import useConsultationsAndAppointments from './useConsultationsAndAppointments'; - -type Props = { - readonly url?: string; - readonly page?: number; - readonly perpage?: number; - - readonly filter?: UserFilter<ConsultationDocument>; - readonly sort: object; - readonly defaultExpandedFirst?: boolean; -} & Omit<PropsOf<typeof PagedConsultationsList>, 'page' | 'perpage' | 'items'>; - -const ConsultationsPage = ({ - filter = {}, - sort, - page = 1, - perpage = 10, - url, - defaultExpandedFirst = false, - ...rest -}: Props) => { - const query = { - filter, - sort, - skip: (page - 1) * perpage, - limit: perpage, - }; - - const deps = [JSON.stringify(query)]; - - const {loading, results: items} = useConsultationsAndAppointments( - query, - deps, - ); - - return ( - <PagedConsultationsList - loading={loading} - page={page} - perpage={perpage} - items={items} - defaultExpandedFirst={page === 1 && defaultExpandedFirst} - {...rest} - /> - ); -}; - -export default ConsultationsPage; diff --git a/imports/ui/consultations/PagedConsultationsList.tsx b/imports/ui/consultations/PagedConsultationsList.tsx index 7b71c9b28..7f1b6a837 100644 --- a/imports/ui/consultations/PagedConsultationsList.tsx +++ b/imports/ui/consultations/PagedConsultationsList.tsx @@ -16,8 +16,15 @@ type PagedConsultationsListProps = { readonly loading?: boolean; readonly dirty?: boolean; readonly refresh?: () => void; + readonly LoadingIndicator?: React.ElementType<{}>; + readonly EmptyPage?: React.ElementType<{page: number}>; } & PropsOf<typeof ConsultationsList>; +const DefaultLoadingIndicator = Loading; +const DefaultEmptyPage = ({page}: {readonly page: number}) => ( + <NoContent>{`Nothing to see on page ${page}.`}</NoContent> +); + const PagedConsultationsList = ({ loading = false, page, @@ -25,15 +32,17 @@ const PagedConsultationsList = ({ items, refresh = undefined, dirty = false, + LoadingIndicator = DefaultLoadingIndicator, + EmptyPage = DefaultEmptyPage, ...rest }: PagedConsultationsListProps) => ( <div> {loading && items.length === 0 ? ( - <Loading /> + <LoadingIndicator /> ) : items.length > 0 ? ( <ConsultationsList loading={loading} items={items} {...rest} /> ) : ( - <NoContent>{`Nothing to see on page ${page}.`}</NoContent> + <EmptyPage page={page} /> )} <Paginator loading={loading} end={items.length < perpage} /> {refresh && dirty && <Refresh onClick={refresh} />} diff --git a/imports/ui/consultations/PaidConsultationsList.tsx b/imports/ui/consultations/PaidConsultationsList.tsx index 1f7c3a38a..6a11dac40 100644 --- a/imports/ui/consultations/PaidConsultationsList.tsx +++ b/imports/ui/consultations/PaidConsultationsList.tsx @@ -41,14 +41,14 @@ const PaidConsultationsList = ({year, payment_method = undefined}: Props) => { if (payment_method) filter.payment_method = payment_method; if (!showBookZero) filter.book = {$ne: '0'}; - const sort = {datetime: -1}; + const sort = {datetime: -1} as const; - const genericToURL = (method) => - method - ? (x) => `/paid/year/${x}/payment_method/${method}` - : (x) => `/paid/year/${x}`; + const genericToURL = (method: PaymentMethod | undefined) => + method === undefined + ? (x: number) => `/paid/year/${x}` + : (x: number) => `/paid/year/${x}/payment_method/${method}`; const toURL = genericToURL(payment_method); - const toggle = (method) => + const toggle = (method: PaymentMethod) => payment_method === method ? genericToURL(undefined)(year) : genericToURL(method)(year); diff --git a/imports/ui/consultations/StaticConsultationCardChips.tsx b/imports/ui/consultations/StaticConsultationCardChips.tsx index 0b55188cc..3243c5b37 100644 --- a/imports/ui/consultations/StaticConsultationCardChips.tsx +++ b/imports/ui/consultations/StaticConsultationCardChips.tsx @@ -18,7 +18,10 @@ import UnstyledLinkChip from '../chips/LinkChip'; import {useDateFormat} from '../../i18n/datetime'; import {useCurrencyFormat} from '../../i18n/currency'; import {type PatientDocument} from '../../api/collection/patients'; -import {type ConsultationDocument} from '../../api/collection/consultations'; +import { + type PaymentMethod, + type ConsultationDocument, +} from '../../api/collection/consultations'; import {msToString, msToStringShort} from '../../api/duration'; const Chips = styled('div')(({theme}) => ({ @@ -35,7 +38,8 @@ const additionalProps = new Set<number | string | Symbol>([ 'isDebt', 'bold', ]); -const shouldForwardProp = (prop) => !additionalProps.has(prop); +const shouldForwardProp = (prop: string | number | Symbol) => + !additionalProps.has(prop); const styles: any = ({ theme, didNotOrWillNotHappen, @@ -74,7 +78,7 @@ const LinkChip = styled(UnstyledLinkChip, { shouldForwardProp, })<AdditionalChipProps>(styles); -function paymentMethodIcon(payment_method) { +function paymentMethodIcon(payment_method: PaymentMethod | undefined) { switch (payment_method) { case 'wire': { return <PaymentIcon />; diff --git a/imports/ui/consultations/UnpaidConsultationsList.tsx b/imports/ui/consultations/UnpaidConsultationsList.tsx index 84723ad22..429686294 100644 --- a/imports/ui/consultations/UnpaidConsultationsList.tsx +++ b/imports/ui/consultations/UnpaidConsultationsList.tsx @@ -47,9 +47,9 @@ const UnpaidConsultationsList = ({year}: Props) => { filter.payment_method = {$in: displayedPaymentMethods}; } - const sort = {datetime: 1}; + const sort = {datetime: 1} as const; - const toURL = (x) => `/unpaid/year/${x}`; + const toURL = (x: number) => `/unpaid/year/${x}`; return ( <div> diff --git a/imports/ui/consultations/makeConsultationsPage.tsx b/imports/ui/consultations/makeConsultationsPage.tsx new file mode 100644 index 000000000..0005a9115 --- /dev/null +++ b/imports/ui/consultations/makeConsultationsPage.tsx @@ -0,0 +1,58 @@ +import React from 'react'; + +import type PropsOf from '../../util/types/PropsOf'; + +import type UserFilter from '../../api/query/UserFilter'; +import {type Sort} from '../../api/query/sort'; + +import {type ConsultationDocument} from '../../api/collection/consultations'; + +import type GenericQueryHook from '../../api/GenericQueryHook'; + +import PagedConsultationsList from './PagedConsultationsList'; + +type Props = { + readonly url?: string; + readonly page?: number; + readonly perpage?: number; + + readonly filter?: UserFilter<ConsultationDocument>; + readonly sort: Sort<ConsultationDocument>; + readonly defaultExpandedFirst?: boolean; +} & Omit<PropsOf<typeof PagedConsultationsList>, 'page' | 'perpage' | 'items'>; + +const makeConsultationsPage = + (useConsultations: GenericQueryHook<ConsultationDocument>) => + ({ + filter = {}, + sort, + page = 1, + perpage = 10, + url, + defaultExpandedFirst = false, + ...rest + }: Props) => { + const query = { + filter, + sort, + skip: (page - 1) * perpage, + limit: perpage, + }; + + const deps = [JSON.stringify(query)]; + + const {loading, results: items} = useConsultations(query, deps); + + return ( + <PagedConsultationsList + loading={loading} + page={page} + perpage={perpage} + items={items} + defaultExpandedFirst={page === 1 && defaultExpandedFirst} + {...rest} + /> + ); + }; + +export default makeConsultationsPage; diff --git a/imports/ui/consultations/useConsultationEditorState.ts b/imports/ui/consultations/useConsultationEditorState.ts index 3401e3aba..93de2fe35 100644 --- a/imports/ui/consultations/useConsultationEditorState.ts +++ b/imports/ui/consultations/useConsultationEditorState.ts @@ -206,8 +206,7 @@ const _priceStatus = ({ if (priceString === '') return PriceStatus.SHOULD_NOT_BE_EMPTY; if (!isValidAmount(priceString)) return PriceStatus.SHOULD_NOT_BE_INVALID; const price = parseAmount(priceString); - if (price === 0 && isRealBookNumber(book)) - return PriceStatus.SHOULD_NOT_BE_ZERO; + if (price === 0 && book !== '0') return PriceStatus.SHOULD_NOT_BE_ZERO; return PriceStatus.OK; }; diff --git a/imports/ui/documents/DocumentListItem.tsx b/imports/ui/documents/DocumentListItem.tsx index 1d6967de1..45c7a3d4b 100644 --- a/imports/ui/documents/DocumentListItem.tsx +++ b/imports/ui/documents/DocumentListItem.tsx @@ -63,7 +63,7 @@ const DocumentListItem = ({loading = false, document, ...rest}: Props) => { rel="noreferrer" target="_blank" to={`/document/${_id}`} - aria-label="Open in New Tab" + aria-label={`Open document #${_id} in New Tab`} > <OpenInNewIcon /> </IconButton> diff --git a/imports/ui/documents/DocumentsList.ts b/imports/ui/documents/DocumentsList.ts new file mode 100644 index 000000000..f62e9a593 --- /dev/null +++ b/imports/ui/documents/DocumentsList.ts @@ -0,0 +1,6 @@ +import useDocuments from './useDocuments'; +import makeDocumentsList from './makeDocumentsList'; + +const DocumentsList = makeDocumentsList(useDocuments); + +export default DocumentsList; diff --git a/imports/ui/documents/DocumentsList.tsx b/imports/ui/documents/DocumentsList.tsx deleted file mode 100644 index 12c70a70d..000000000 --- a/imports/ui/documents/DocumentsList.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import React from 'react'; - -import FixedFab from '../button/FixedFab'; - -import useDocuments from './useDocuments'; -import StaticDocumentList from './StaticDocumentList'; -import CustomDocumentImportButton from './CustomDocumentImportButton'; - -type Props = { - readonly page?: number; - readonly perpage?: number; -}; - -const DocumentsList = ({page = 1, perpage = 10}: Props) => { - const query = { - filter: {}, - sort: {createdAt: -1} as const, - projection: StaticDocumentList.projection, - skip: (page - 1) * perpage, - limit: perpage, - }; - - const deps = [page, perpage]; - - const {loading, results: documents} = useDocuments(query, deps); - - return ( - <> - <StaticDocumentList - page={page} - perpage={perpage} - loading={loading} - documents={documents} - /> - <CustomDocumentImportButton - Button={FixedFab} - col={4} - tooltip="Import documents" - /> - </> - ); -}; - -export default DocumentsList; diff --git a/imports/ui/documents/DocumentsListRoutes.ts b/imports/ui/documents/DocumentsListRoutes.ts deleted file mode 100644 index 3ca268636..000000000 --- a/imports/ui/documents/DocumentsListRoutes.ts +++ /dev/null @@ -1,7 +0,0 @@ -import paged from '../routes/paged'; - -import DocumentsList from './DocumentsList'; - -const DocumentsListRoutes = paged(DocumentsList); - -export default DocumentsListRoutes; diff --git a/imports/ui/documents/DocumentsListRoutes.tsx b/imports/ui/documents/DocumentsListRoutes.tsx new file mode 100644 index 000000000..81f7151eb --- /dev/null +++ b/imports/ui/documents/DocumentsListRoutes.tsx @@ -0,0 +1,24 @@ +import React from 'react'; + +import paged from '../routes/paged'; + +import FixedFab from '../button/FixedFab'; + +import DocumentsList from './DocumentsList'; + +import CustomDocumentImportButton from './CustomDocumentImportButton'; + +const DocumentsPager = paged(DocumentsList); + +const DocumentsListRoutes = () => ( + <> + <DocumentsPager sort={{createdAt: -1}} /> + <CustomDocumentImportButton + Button={FixedFab} + col={4} + tooltip="Import documents" + /> + </> +); + +export default DocumentsListRoutes; diff --git a/imports/ui/documents/StaticDocumentList.tsx b/imports/ui/documents/StaticDocumentList.tsx index bd852972e..99ffc9242 100644 --- a/imports/ui/documents/StaticDocumentList.tsx +++ b/imports/ui/documents/StaticDocumentList.tsx @@ -13,22 +13,31 @@ type Props = { readonly perpage: number; readonly loading?: boolean; readonly documents: DocumentDocument[]; + readonly LoadingIndicator?: React.ElementType<{}>; + readonly EmptyPage?: React.ElementType<{page: number}>; }; +const DefaultLoadingIndicator = Loading; +const DefaultEmptyPage = ({page}: {readonly page: number}) => ( + <NoContent>{`Nothing to see on page ${page}.`}</NoContent> +); + const StaticDocumentList = ({ page, perpage, loading = false, documents, + LoadingIndicator = DefaultLoadingIndicator, + EmptyPage = DefaultEmptyPage, }: Props) => ( <> <div> {loading && documents.length === 0 ? ( - <Loading /> + <LoadingIndicator /> ) : documents.length > 0 ? ( <DocumentsPage loading={loading} documents={documents} /> ) : ( - <NoContent>{`Nothing to see on page ${page}.`}</NoContent> + <EmptyPage page={page} /> )} </div> <Paginator loading={loading} end={documents.length < perpage} /> diff --git a/imports/ui/documents/makeDocumentsList.tsx b/imports/ui/documents/makeDocumentsList.tsx new file mode 100644 index 000000000..bc85e585a --- /dev/null +++ b/imports/ui/documents/makeDocumentsList.tsx @@ -0,0 +1,46 @@ +import React from 'react'; + +import type PropsOf from '../../util/types/PropsOf'; + +import {type DocumentDocument} from '../../api/collection/documents'; +import type UserFilter from '../../api/query/UserFilter'; +import {type Sort} from '../../api/query/sort'; +import type GenericQueryHook from '../../api/GenericQueryHook'; + +import StaticDocumentList from './StaticDocumentList'; + +type Props = { + readonly filter?: UserFilter<DocumentDocument>; + readonly sort: Sort<DocumentDocument>; + + readonly page?: number; + readonly perpage?: number; +} & Omit<PropsOf<typeof StaticDocumentList>, 'page' | 'perpage' | 'documents'>; + +const makeDocumentsList = + (useDocuments: GenericQueryHook<DocumentDocument>) => + ({filter = {}, sort, page = 1, perpage = 10, ...rest}: Props) => { + const query = { + filter, + sort, + projection: StaticDocumentList.projection, + skip: (page - 1) * perpage, + limit: perpage, + }; + + const deps = [page, perpage]; + + const {loading, results: documents} = useDocuments(query, deps); + + return ( + <StaticDocumentList + page={page} + perpage={perpage} + loading={loading} + documents={documents} + {...rest} + /> + ); + }; + +export default makeDocumentsList; diff --git a/imports/ui/issues/ConsultationsMissingABook.tests.tsx b/imports/ui/issues/ConsultationsMissingABook.tests.tsx new file mode 100644 index 000000000..8951a02f9 --- /dev/null +++ b/imports/ui/issues/ConsultationsMissingABook.tests.tsx @@ -0,0 +1,184 @@ +import {assert} from 'chai'; + +import React from 'react'; + +import {BrowserRouter} from 'react-router-dom'; + +import {list} from '@iterable-iterator/list'; +import {map} from '@iterable-iterator/map'; +import {range} from '@iterable-iterator/range'; + +import {client, randomPassword, randomUserId} from '../../_test/fixtures'; +import {render as _render, waitFor} from '../../_test/react'; + +import createUserWithPasswordAndLogin from '../../api/user/createUserWithPasswordAndLogin'; + +import {type PatientDocument} from '../../api/collection/patients'; +import {newPatientFormData} from '../../api/_dev/populate/patients'; +import {newConsultationFormData} from '../../api/_dev/populate/consultations'; + +import call from '../../api/endpoint/call'; +import patientsInsert from '../../api/endpoint/patients/insert'; +import consultationsInsert from '../../api/endpoint/consultations/insert'; +import consultationsUpdate from '../../api/endpoint/consultations/update'; + +import DateTimeLocalizationProvider from '../i18n/DateTimeLocalizationProvider'; + +import ConsultationsMissingABook from './ConsultationsMissingABook'; + +const textNoIssues = 'All consultations have a book :)'; + +const patientLinkName = ({ + firstname, + lastname, +}: Pick<PatientDocument, 'firstname' | 'lastname'>) => { + return `${lastname} ${firstname}`; +}; + +const render = (children: React.ReactNode) => + _render(children, { + wrapper: ({children}: {children: React.ReactNode}) => ( + <DateTimeLocalizationProvider> + <BrowserRouter>{children}</BrowserRouter> + </DateTimeLocalizationProvider> + ), + }); + +client(__filename, () => { + it('renders when not logged in', async () => { + const {findByText} = render(<ConsultationsMissingABook />); + await findByText(textNoIssues); + }); + + it('renders when there are no issues', async () => { + const username = randomUserId(); + const password = randomPassword(); + await createUserWithPasswordAndLogin(username, password); + + const {findByText} = render(<ConsultationsMissingABook />); + + await findByText(textNoIssues); + }); + + it('rerenders when a new issue is created', async () => { + const username = randomUserId(); + const password = randomPassword(); + await createUserWithPasswordAndLogin(username, password); + + const {findByRole, findByText} = render(<ConsultationsMissingABook />); + + await findByText(textNoIssues); + + const patient = newPatientFormData(); + const patientId = await call(patientsInsert, patient); + const consultation = newConsultationFormData({ + patientId, + book: '', + }); + await call(consultationsInsert, consultation); + + await findByRole('link', {name: patientLinkName(patient)}); + }); + + it('rerenders when an issue is fixed', async () => { + const username = randomUserId(); + const password = randomPassword(); + await createUserWithPasswordAndLogin(username, password); + + const patient = newPatientFormData(); + const patientId = await call(patientsInsert, patient); + const consultation = newConsultationFormData({ + patientId, + book: '', + }); + const {insertedId: consultationId} = await call( + consultationsInsert, + consultation, + ); + + const {findByRole, findByText} = render(<ConsultationsMissingABook />); + + await findByRole('link', {name: patientLinkName(patient)}); + + await call(consultationsUpdate, consultationId, {book: 'book-name'}); + + await findByText(textNoIssues); + }); + + it('paginates beyond 10 issues', async () => { + const {setupUser} = await import('../../../test/app/client/fixtures'); + const username = randomUserId(); + const password = randomPassword(); + await createUserWithPasswordAndLogin(username, password); + + const n = 13; + + const patient = newPatientFormData(); + const patientId = await call(patientsInsert, patient); + + await Promise.all( + list( + map( + async (_: number) => + call( + consultationsInsert, + newConsultationFormData({ + patientId, + book: '', + }), + ), + range(n), + ), + ), + ); + + const {findByRole, getAllByRole} = render(<ConsultationsMissingABook />); + + const {user} = setupUser(); + + await user.click(await findByRole('link', {name: 'Page 2'})); + + await waitFor(() => { + const listed = getAllByRole('link', { + name: patientLinkName(patient), + }); + assert.lengthOf(listed, n - 10); + }); + }); + + it('paginates beyond 10 issues (empty)', async () => { + const {setupUser} = await import('../../../test/app/client/fixtures'); + const username = randomUserId(); + const password = randomPassword(); + await createUserWithPasswordAndLogin(username, password); + + const n = 10; + + const patient = newPatientFormData(); + const patientId = await call(patientsInsert, patient); + + await Promise.all( + list( + map( + async (_: number) => + call( + consultationsInsert, + newConsultationFormData({ + patientId, + book: '', + }), + ), + range(n), + ), + ), + ); + + const {findByRole, findByText} = render(<ConsultationsMissingABook />); + + const {user} = setupUser(); + + await user.click(await findByRole('link', {name: 'Page 2'})); + + await findByText('Nothing to see on page 2.'); + }); +}); diff --git a/imports/ui/issues/ConsultationsMissingABook.tsx b/imports/ui/issues/ConsultationsMissingABook.tsx index 770257e96..5aea86633 100644 --- a/imports/ui/issues/ConsultationsMissingABook.tsx +++ b/imports/ui/issues/ConsultationsMissingABook.tsx @@ -2,29 +2,39 @@ import React from 'react'; import {useConsultationsMissingABook} from '../../api/issues'; -import ReactiveConsultationCard from '../consultations/ReactiveConsultationCard'; -import ReactivePatientChip from '../patients/ReactivePatientChip'; +import paged from '../routes/paged'; + +import makeConsultationsPage from '../consultations/makeConsultationsPage'; -const ConsultationsMissingABook = (props) => { - const {loading, results: consultations} = useConsultationsMissingABook(); +import ReactivePatientChip from '../patients/ReactivePatientChip'; - if (loading) { - return <div {...props}>Loading...</div>; - } +const ConsultationsPage = makeConsultationsPage(useConsultationsMissingABook); +const ConsultationsPager = paged(ConsultationsPage); - if (consultations.length === 0) { - return <div {...props}>All consultations have a book :)</div>; - } +type Props = React.JSX.IntrinsicAttributes & + React.ClassAttributes<HTMLDivElement> & + React.HTMLAttributes<HTMLDivElement>; +const ConsultationsMissingABook = (props: Props) => { return ( <div {...props}> - {consultations.map((consultation) => ( - <ReactiveConsultationCard - key={consultation._id} - consultation={consultation} - PatientChip={ReactivePatientChip} - /> - ))} + <ConsultationsPager + sort={{ + datetime: -1, + }} + LoadingIndicator={(_: {}) => <>Loading...</>} + EmptyPage={({page}: {readonly page: number}) => + page === 1 ? ( + <>All consultations have a book :)</> + ) : ( + // eslint-disable-next-line react/jsx-no-useless-fragment + <>{`Nothing to see on page ${page}.`}</> + ) + } + itemProps={{ + PatientChip: ReactivePatientChip, + }} + /> </div> ); }; diff --git a/imports/ui/issues/ConsultationsMissingPaymentData.tests.tsx b/imports/ui/issues/ConsultationsMissingPaymentData.tests.tsx new file mode 100644 index 000000000..00a3801cc --- /dev/null +++ b/imports/ui/issues/ConsultationsMissingPaymentData.tests.tsx @@ -0,0 +1,209 @@ +import {assert} from 'chai'; +import {faker} from '@faker-js/faker'; + +import React from 'react'; + +import {BrowserRouter} from 'react-router-dom'; + +import {list} from '@iterable-iterator/list'; +import {map} from '@iterable-iterator/map'; +import {range} from '@iterable-iterator/range'; + +import {client, randomPassword, randomUserId} from '../../_test/fixtures'; +import {render as _render, waitFor} from '../../_test/react'; + +import createUserWithPasswordAndLogin from '../../api/user/createUserWithPasswordAndLogin'; + +import {type PatientDocument} from '../../api/collection/patients'; +import {newPatientFormData} from '../../api/_dev/populate/patients'; +import {newConsultationFormData} from '../../api/_dev/populate/consultations'; + +import call from '../../api/endpoint/call'; +import patientsInsert from '../../api/endpoint/patients/insert'; +import consultationsInsert from '../../api/endpoint/consultations/insert'; +import consultationsUpdate from '../../api/endpoint/consultations/update'; + +import DateTimeLocalizationProvider from '../i18n/DateTimeLocalizationProvider'; + +import ConsultationsMissingPaymentData from './ConsultationsMissingPaymentData'; + +const textNoIssues = 'All consultations have payment data :)'; + +const patientLinkName = ({ + firstname, + lastname, +}: Pick<PatientDocument, 'firstname' | 'lastname'>) => { + return `${lastname} ${firstname}`; +}; + +const render = (children: React.ReactNode) => + _render(children, { + wrapper: ({children}: {children: React.ReactNode}) => ( + <DateTimeLocalizationProvider> + <BrowserRouter>{children}</BrowserRouter> + </DateTimeLocalizationProvider> + ), + }); + +client(__filename, () => { + it('renders when not logged in', async () => { + const {findByText} = render(<ConsultationsMissingPaymentData />); + await findByText(textNoIssues); + }); + + it('renders when there are no issues', async () => { + const username = randomUserId(); + const password = randomPassword(); + await createUserWithPasswordAndLogin(username, password); + + const {findByText} = render(<ConsultationsMissingPaymentData />); + + await findByText(textNoIssues); + }); + + it('rerenders when a new issue is created', async () => { + const username = randomUserId(); + const password = randomPassword(); + await createUserWithPasswordAndLogin(username, password); + + const {findByRole, findByText} = render( + <ConsultationsMissingPaymentData />, + ); + + await findByText(textNoIssues); + + const patient = newPatientFormData(); + const patientId = await call(patientsInsert, patient); + const consultation = newConsultationFormData({ + datetime: faker.date.between({ + from: new Date(2020, 0, 1), + to: new Date(), + }), + patientId, + price: undefined, + }); + await call(consultationsInsert, consultation); + + await findByRole('link', {name: patientLinkName(patient)}); + }); + + it('rerenders when an issue is fixed', async () => { + const username = randomUserId(); + const password = randomPassword(); + await createUserWithPasswordAndLogin(username, password); + + const patient = newPatientFormData(); + const patientId = await call(patientsInsert, patient); + const consultation = newConsultationFormData({ + datetime: faker.date.between({ + from: new Date(2020, 0, 1), + to: new Date(), + }), + patientId, + paid: undefined, + }); + const {insertedId: consultationId} = await call( + consultationsInsert, + consultation, + ); + + const {findByRole, findByText} = render( + <ConsultationsMissingPaymentData />, + ); + + await findByRole('link', {name: patientLinkName(patient)}); + + await call(consultationsUpdate, consultationId, {price: 10, paid: 10}); + + await findByText(textNoIssues); + }); + + it('paginates beyond 10 issues', async () => { + const {setupUser} = await import('../../../test/app/client/fixtures'); + const username = randomUserId(); + const password = randomPassword(); + await createUserWithPasswordAndLogin(username, password); + + const n = 13; + + const patient = newPatientFormData(); + const patientId = await call(patientsInsert, patient); + + await Promise.all( + list( + map( + async (_: number) => + call( + consultationsInsert, + newConsultationFormData({ + datetime: faker.date.between({ + from: new Date(2020, 0, 1), + to: new Date(), + }), + patientId, + currency: undefined, + }), + ), + range(n), + ), + ), + ); + + const {findByRole, getAllByRole} = render( + <ConsultationsMissingPaymentData />, + ); + + const {user} = setupUser(); + + await user.click(await findByRole('link', {name: 'Page 2'})); + + await waitFor(() => { + const listed = getAllByRole('link', { + name: patientLinkName(patient), + }); + assert.lengthOf(listed, n - 10); + }); + }); + + it('paginates beyond 10 issues (empty)', async () => { + const {setupUser} = await import('../../../test/app/client/fixtures'); + const username = randomUserId(); + const password = randomPassword(); + await createUserWithPasswordAndLogin(username, password); + + const n = 10; + + const patient = newPatientFormData(); + const patientId = await call(patientsInsert, patient); + + await Promise.all( + list( + map( + async (_: number) => + call( + consultationsInsert, + newConsultationFormData({ + datetime: faker.date.between({ + from: new Date(2020, 0, 1), + to: new Date(), + }), + patientId, + payment_method: undefined, + }), + ), + range(n), + ), + ), + ); + + const {findByRole, findByText} = render( + <ConsultationsMissingPaymentData />, + ); + + const {user} = setupUser(); + + await user.click(await findByRole('link', {name: 'Page 2'})); + + await findByText('Nothing to see on page 2.'); + }); +}); diff --git a/imports/ui/issues/ConsultationsMissingPaymentData.tsx b/imports/ui/issues/ConsultationsMissingPaymentData.tsx index 7e35f7f6c..13c892538 100644 --- a/imports/ui/issues/ConsultationsMissingPaymentData.tsx +++ b/imports/ui/issues/ConsultationsMissingPaymentData.tsx @@ -2,47 +2,53 @@ import React from 'react'; import {useConsultationsMissingPaymentData} from '../../api/issues'; -import ReactiveConsultationCard from '../consultations/ReactiveConsultationCard'; -import ReactivePatientChip from '../patients/ReactivePatientChip'; +import paged from '../routes/paged'; -const ConsultationsMissingPaymentData = (props) => { - const {loading, results: consultations} = useConsultationsMissingPaymentData( - { - isDone: true, - datetime: {$gte: new Date(2020, 0, 1)}, - $or: [ - {price: {$not: {$type: 1}}}, - {price: Number.NaN}, - {paid: {$not: {$type: 1}}}, - {paid: Number.NaN}, - {currency: {$not: {$type: 2}}}, - {payment_method: {$not: {$type: 2}}}, - ], - }, - { - sort: { - datetime: -1, - }, - }, - ); +import makeConsultationsPage from '../consultations/makeConsultationsPage'; + +import ReactivePatientChip from '../patients/ReactivePatientChip'; - if (loading) { - return <div {...props}>Loading...</div>; - } +const ConsultationsPage = makeConsultationsPage( + useConsultationsMissingPaymentData, +); +const ConsultationsPager = paged(ConsultationsPage); - if (consultations.length === 0) { - return <div {...props}>All consultations have payment data :)</div>; - } +type Props = React.JSX.IntrinsicAttributes & + React.ClassAttributes<HTMLDivElement> & + React.HTMLAttributes<HTMLDivElement>; +const ConsultationsMissingPaymentData = (props: Props) => { return ( <div {...props}> - {consultations.map((consultation) => ( - <ReactiveConsultationCard - key={consultation._id} - consultation={consultation} - PatientChip={ReactivePatientChip} - /> - ))} + <ConsultationsPager + filter={{ + isDone: true, + datetime: {$gte: new Date(2020, 0, 1)}, + $or: [ + {price: {$not: {$type: 1}}}, + {price: Number.NaN}, + {paid: {$not: {$type: 1}}}, + {paid: Number.NaN}, + {currency: {$not: {$type: 2}}}, + {payment_method: {$not: {$type: 2}}}, + ], + }} + sort={{ + datetime: -1, + }} + LoadingIndicator={(_: {}) => <>Loading...</>} + EmptyPage={({page}: {readonly page: number}) => + page === 1 ? ( + <>All consultations have payment data :)</> + ) : ( + // eslint-disable-next-line react/jsx-no-useless-fragment + <>{`Nothing to see on page ${page}.`}</> + ) + } + itemProps={{ + PatientChip: ReactivePatientChip, + }} + /> </div> ); }; diff --git a/imports/ui/issues/ConsultationsWithPriceZeroNotInBookZero.tests.tsx b/imports/ui/issues/ConsultationsWithPriceZeroNotInBookZero.tests.tsx new file mode 100644 index 000000000..b7383b528 --- /dev/null +++ b/imports/ui/issues/ConsultationsWithPriceZeroNotInBookZero.tests.tsx @@ -0,0 +1,196 @@ +import {assert} from 'chai'; + +import React from 'react'; + +import {BrowserRouter} from 'react-router-dom'; + +import {list} from '@iterable-iterator/list'; +import {map} from '@iterable-iterator/map'; +import {range} from '@iterable-iterator/range'; + +import {client, randomPassword, randomUserId} from '../../_test/fixtures'; +import {render as _render, waitFor} from '../../_test/react'; + +import createUserWithPasswordAndLogin from '../../api/user/createUserWithPasswordAndLogin'; + +import {type PatientDocument} from '../../api/collection/patients'; +import {newPatientFormData} from '../../api/_dev/populate/patients'; +import {newConsultationFormData} from '../../api/_dev/populate/consultations'; + +import call from '../../api/endpoint/call'; +import patientsInsert from '../../api/endpoint/patients/insert'; +import consultationsInsert from '../../api/endpoint/consultations/insert'; +import consultationsUpdate from '../../api/endpoint/consultations/update'; + +import DateTimeLocalizationProvider from '../i18n/DateTimeLocalizationProvider'; + +import ConsultationsWithPriceZeroNotInBookZero from './ConsultationsWithPriceZeroNotInBookZero'; + +const textNoIssues = 'All price 0 consultations are in book 0 :)'; + +const patientLinkName = ({ + firstname, + lastname, +}: Pick<PatientDocument, 'firstname' | 'lastname'>) => { + return `${lastname} ${firstname}`; +}; + +const render = (children: React.ReactNode) => + _render(children, { + wrapper: ({children}: {children: React.ReactNode}) => ( + <DateTimeLocalizationProvider> + <BrowserRouter>{children}</BrowserRouter> + </DateTimeLocalizationProvider> + ), + }); + +client(__filename, () => { + it('renders when not logged in', async () => { + const {findByText} = render(<ConsultationsWithPriceZeroNotInBookZero />); + await findByText(textNoIssues); + }); + + it('renders when there are no issues', async () => { + const username = randomUserId(); + const password = randomPassword(); + await createUserWithPasswordAndLogin(username, password); + + const {findByText} = render(<ConsultationsWithPriceZeroNotInBookZero />); + + await findByText(textNoIssues); + }); + + it('rerenders when a new issue is created', async () => { + const username = randomUserId(); + const password = randomPassword(); + await createUserWithPasswordAndLogin(username, password); + + const {findByRole, findByText} = render( + <ConsultationsWithPriceZeroNotInBookZero />, + ); + + await findByText(textNoIssues); + + const patient = newPatientFormData(); + const patientId = await call(patientsInsert, patient); + const consultation = newConsultationFormData({ + patientId, + price: 0, + book: '1', + }); + await call(consultationsInsert, consultation); + + await findByRole('link', {name: patientLinkName(patient)}); + }); + + it('rerenders when an issue is fixed', async () => { + const username = randomUserId(); + const password = randomPassword(); + await createUserWithPasswordAndLogin(username, password); + + const patient = newPatientFormData(); + const patientId = await call(patientsInsert, patient); + const consultation = newConsultationFormData({ + patientId, + price: 0, + book: 'abcd', + }); + const {insertedId: consultationId} = await call( + consultationsInsert, + consultation, + ); + + const {findByRole, findByText} = render( + <ConsultationsWithPriceZeroNotInBookZero />, + ); + + await findByRole('link', {name: patientLinkName(patient)}); + + await call(consultationsUpdate, consultationId, {book: '0'}); + + await findByText(textNoIssues); + }); + + it('paginates beyond 10 issues', async () => { + const {setupUser} = await import('../../../test/app/client/fixtures'); + const username = randomUserId(); + const password = randomPassword(); + await createUserWithPasswordAndLogin(username, password); + + const n = 13; + + const patient = newPatientFormData(); + const patientId = await call(patientsInsert, patient); + + await Promise.all( + list( + map( + async (_: number) => + call( + consultationsInsert, + newConsultationFormData({ + patientId, + price: 0, + book: '', + }), + ), + range(n), + ), + ), + ); + + const {findByRole, getAllByRole} = render( + <ConsultationsWithPriceZeroNotInBookZero />, + ); + + const {user} = setupUser(); + + await user.click(await findByRole('link', {name: 'Page 2'})); + + await waitFor(() => { + const listed = getAllByRole('link', { + name: patientLinkName(patient), + }); + assert.lengthOf(listed, n - 10); + }); + }); + + it('paginates beyond 10 issues (empty)', async () => { + const {setupUser} = await import('../../../test/app/client/fixtures'); + const username = randomUserId(); + const password = randomPassword(); + await createUserWithPasswordAndLogin(username, password); + + const n = 10; + + const patient = newPatientFormData(); + const patientId = await call(patientsInsert, patient); + + await Promise.all( + list( + map( + async (_: number) => + call( + consultationsInsert, + newConsultationFormData({ + patientId, + price: 0, + book: undefined, + }), + ), + range(n), + ), + ), + ); + + const {findByRole, findByText} = render( + <ConsultationsWithPriceZeroNotInBookZero />, + ); + + const {user} = setupUser(); + + await user.click(await findByRole('link', {name: 'Page 2'})); + + await findByText('Nothing to see on page 2.'); + }); +}); diff --git a/imports/ui/issues/ConsultationsWithPriceZeroNotInBookZero.tsx b/imports/ui/issues/ConsultationsWithPriceZeroNotInBookZero.tsx index 2c0968e1b..94ff8bba9 100644 --- a/imports/ui/issues/ConsultationsWithPriceZeroNotInBookZero.tsx +++ b/imports/ui/issues/ConsultationsWithPriceZeroNotInBookZero.tsx @@ -2,30 +2,41 @@ import React from 'react'; import {useConsultationsWithPriceZeroNotInBookZero} from '../../api/issues'; -import ReactiveConsultationCard from '../consultations/ReactiveConsultationCard'; -import ReactivePatientChip from '../patients/ReactivePatientChip'; +import paged from '../routes/paged'; + +import makeConsultationsPage from '../consultations/makeConsultationsPage'; -const ConsultationsWithPriceZeroNotInBookZero = (props) => { - const {loading, results: consultations} = - useConsultationsWithPriceZeroNotInBookZero(); +import ReactivePatientChip from '../patients/ReactivePatientChip'; - if (loading) { - return <div {...props}>Loading...</div>; - } +const ConsultationsPage = makeConsultationsPage( + useConsultationsWithPriceZeroNotInBookZero, +); +const ConsultationsPager = paged(ConsultationsPage); - if (consultations.length === 0) { - return <div {...props}>All price 0 consultations are in book 0 :)</div>; - } +type Props = React.JSX.IntrinsicAttributes & + React.ClassAttributes<HTMLDivElement> & + React.HTMLAttributes<HTMLDivElement>; +const ConsultationsWithPriceZeroNotInBookZero = (props: Props) => { return ( <div {...props}> - {consultations.map((consultation) => ( - <ReactiveConsultationCard - key={consultation._id} - consultation={consultation} - PatientChip={ReactivePatientChip} - /> - ))} + <ConsultationsPager + sort={{ + datetime: -1, + }} + LoadingIndicator={(_: {}) => <>Loading...</>} + EmptyPage={({page}: {readonly page: number}) => + page === 1 ? ( + <>All price 0 consultations are in book 0 :)</> + ) : ( + // eslint-disable-next-line react/jsx-no-useless-fragment + <>{`Nothing to see on page ${page}.`}</> + ) + } + itemProps={{ + PatientChip: ReactivePatientChip, + }} + /> </div> ); }; diff --git a/imports/ui/issues/DoctorsWithNonAlphabeticalSymbols.tests.tsx b/imports/ui/issues/DoctorsWithNonAlphabeticalSymbols.tests.tsx new file mode 100644 index 000000000..59f5325dc --- /dev/null +++ b/imports/ui/issues/DoctorsWithNonAlphabeticalSymbols.tests.tsx @@ -0,0 +1,287 @@ +import {assert} from 'chai'; + +import React from 'react'; + +import {BrowserRouter} from 'react-router-dom'; + +import {faker} from '@faker-js/faker'; + +import {list} from '@iterable-iterator/list'; +import {map} from '@iterable-iterator/map'; +import {range} from '@iterable-iterator/range'; + +import {client, randomPassword, randomUserId} from '../../_test/fixtures'; +import {render as _render, renderHook, waitFor} from '../../_test/react'; + +import createUserWithPasswordAndLogin from '../../api/user/createUserWithPasswordAndLogin'; + +import {newPatientFormData} from '../../api/_dev/populate/patients'; + +import call from '../../api/endpoint/call'; +import patientsInsert from '../../api/endpoint/patients/insert'; + +import DateTimeLocalizationProvider from '../i18n/DateTimeLocalizationProvider'; + +import { + type FormattedLine, + formattedLine, + normalizedLine, +} from '../../api/string'; +import {type PatientDocument} from '../../api/collection/patients'; +import {deleteDoctor, renameDoctor, useDoctor} from '../../api/doctors'; + +import DoctorsWithNonAlphabeticalSymbols from './DoctorsWithNonAlphabeticalSymbols'; + +const textNoIssues = 'All doctors are made of alphabetical symbols only :)'; +const patientLinkName = ({ + firstname, + lastname, +}: Pick<PatientDocument, 'firstname' | 'lastname'>) => { + return `${lastname} ${firstname}`; +}; + +const doctorLinkName = (displayName: FormattedLine, patientCount: number) => + `Dr ${displayName} soigne ${patientCount} patients`; + +const render = (children: React.ReactNode) => + _render(children, { + wrapper: ({children}: {children: React.ReactNode}) => ( + <DateTimeLocalizationProvider> + <BrowserRouter>{children}</BrowserRouter> + </DateTimeLocalizationProvider> + ), + }); + +client(__filename, () => { + it('renders when not logged in', async () => { + const {findByText} = render(<DoctorsWithNonAlphabeticalSymbols />); + await findByText(textNoIssues); + }); + + it('renders when there are no issues', async () => { + const username = randomUserId(); + const password = randomPassword(); + await createUserWithPasswordAndLogin(username, password); + + const {findByText} = render(<DoctorsWithNonAlphabeticalSymbols />); + + await findByText(textNoIssues); + }); + + it('rerenders when a new issue is created', async () => { + const username = randomUserId(); + const password = randomPassword(); + await createUserWithPasswordAndLogin(username, password); + + const {findByRole, findByText} = render( + <DoctorsWithNonAlphabeticalSymbols />, + ); + + await findByText(textNoIssues); + + const displayName = '@#$!'; + + const doctor = { + displayName: formattedLine(displayName), + name: normalizedLine(displayName), + }; + + const patient = newPatientFormData({ + doctors: [doctor], + }); + + await call(patientsInsert, patient); + + await findByRole('link', {name: doctorLinkName(doctor.displayName, 1)}); + await findByRole('link', {name: patientLinkName(patient)}); + }); + + it('rerenders when a doctor is renamed to a name without issue', async () => { + const username = randomUserId(); + const password = randomPassword(); + await createUserWithPasswordAndLogin(username, password); + + const displayName = '@#$!'; + + const doctor = { + displayName: formattedLine(displayName), + name: normalizedLine(displayName), + }; + + const patient = newPatientFormData({ + doctors: [doctor], + }); + + await call(patientsInsert, patient); + + const {result} = renderHook(() => useDoctor(doctor.name, [])); + + const {findByRole, findByText} = render( + <DoctorsWithNonAlphabeticalSymbols />, + ); + + await findByRole('link', {name: doctorLinkName(doctor.displayName, 1)}); + await findByRole('link', {name: patientLinkName(patient)}); + + await waitFor(() => { + assert(!result.current.loading); + }); + + await call( + renameDoctor, + result.current.item!._id, + `${faker.person.lastName()} ${faker.person.firstName()}`, + ); + + await findByText(textNoIssues); + }); + + it('rerenders when a doctor is renamed to a name with issue', async () => { + const username = randomUserId(); + const password = randomPassword(); + await createUserWithPasswordAndLogin(username, password); + + const displayName = `${faker.person.lastName()} ${faker.person.firstName()}`; + + const doctor = { + displayName: formattedLine(displayName), + name: normalizedLine(displayName), + }; + + const patient = newPatientFormData({ + doctors: [doctor], + }); + + await call(patientsInsert, patient); + + const {result} = renderHook(() => useDoctor(doctor.name, [])); + + const {findByRole, findByText} = render( + <DoctorsWithNonAlphabeticalSymbols />, + ); + + await findByText(textNoIssues); + + await waitFor(() => { + assert(!result.current.loading); + }); + + const nameWithIssue = '@#$!'; + + await call(renameDoctor, result.current.item!._id, nameWithIssue); + + await findByRole('link', { + name: doctorLinkName(formattedLine(nameWithIssue), 1), + }); + await findByRole('link', {name: patientLinkName(patient)}); + }); + + it('rerenders when a doctor is deleted', async () => { + const username = randomUserId(); + const password = randomPassword(); + await createUserWithPasswordAndLogin(username, password); + + const displayName = '@#$!'; + + const doctor = { + displayName: formattedLine(displayName), + name: normalizedLine(displayName), + }; + + const patient = newPatientFormData({ + doctors: [doctor], + }); + + await call(patientsInsert, patient); + + const {result} = renderHook(() => useDoctor(doctor.name, [])); + + const {findByRole, findByText} = render( + <DoctorsWithNonAlphabeticalSymbols />, + ); + + await findByRole('link', {name: doctorLinkName(doctor.displayName, 1)}); + await findByRole('link', {name: patientLinkName(patient)}); + + await waitFor(() => { + assert(!result.current.loading); + }); + + await call(deleteDoctor, result.current.item!._id); + + await findByText(textNoIssues); + }); + + it('paginates beyond 10 issues', async () => { + const {setupUser} = await import('../../../test/app/client/fixtures'); + const username = randomUserId(); + const password = randomPassword(); + await createUserWithPasswordAndLogin(username, password); + + const n = 13; + + await call( + patientsInsert, + newPatientFormData({ + doctors: list( + map( + (i: number) => ({ + displayName: formattedLine(`#${i}`), + name: normalizedLine(`#${i}`), + }), + range(n), + ), + ), + }), + ); + + const {findByRole, getAllByRole} = render( + <DoctorsWithNonAlphabeticalSymbols />, + ); + + const {user} = setupUser(); + + await user.click(await findByRole('link', {name: 'Page 2'})); + + await waitFor(() => { + const listed = getAllByRole('link', { + name: /^Dr #\d+ soigne \d+ patients$/, + }); + assert.lengthOf(listed, n - 10); + }); + }); + + it('paginates beyond 10 issues (empty)', async () => { + const {setupUser} = await import('../../../test/app/client/fixtures'); + const username = randomUserId(); + const password = randomPassword(); + await createUserWithPasswordAndLogin(username, password); + + const n = 10; + + await call( + patientsInsert, + newPatientFormData({ + doctors: list( + map( + (i: number) => ({ + displayName: formattedLine(`#${i}`), + name: normalizedLine(`#${i}`), + }), + range(n), + ), + ), + }), + ); + + const {findByRole, findByText} = render( + <DoctorsWithNonAlphabeticalSymbols />, + ); + + const {user} = setupUser(); + + await user.click(await findByRole('link', {name: 'Page 2'})); + + await findByText('Nothing to see on page 2.'); + }); +}); diff --git a/imports/ui/issues/DoctorsWithNonAlphabeticalSymbols.tsx b/imports/ui/issues/DoctorsWithNonAlphabeticalSymbols.tsx index 970eeeec5..86d1ebd90 100644 --- a/imports/ui/issues/DoctorsWithNonAlphabeticalSymbols.tsx +++ b/imports/ui/issues/DoctorsWithNonAlphabeticalSymbols.tsx @@ -1,26 +1,36 @@ import React from 'react'; -import {useDoctors} from '../../api/doctors'; -import TagGrid from '../tags/TagGrid'; +import {useDoctorsWithNonAlphabeticalSymbols} from '../../api/issues'; + import StaticDoctorCard from '../doctors/StaticDoctorCard'; +import TagListPage from '../tags/TagListPage'; -const DoctorsWithNonAlphabeticalSymbols = (props) => { - const query = { - filter: {containsNonAlphabetical: true}, - }; - const {loading, results: tags} = useDoctors(query, []); +import paged from '../routes/paged'; - if (loading) { - return <div {...props}>Loading...</div>; - } +const TagList = paged(TagListPage); - if (tags.length === 0) { - return ( - <div {...props}>All doctors are made of alphabetical symbols only :)</div> - ); - } +type Props = React.JSX.IntrinsicAttributes & + React.ClassAttributes<HTMLDivElement> & + React.HTMLAttributes<HTMLDivElement>; - return <TagGrid {...props} Card={StaticDoctorCard} tags={tags} />; +const DoctorsWithNonAlphabeticalSymbols = (props: Props) => { + return ( + <div {...props}> + <TagList + Card={StaticDoctorCard} + useTags={useDoctorsWithNonAlphabeticalSymbols} + LoadingIndicator={(_: {}) => <>Loading...</>} + EmptyPage={({page}: {readonly page: number}) => + page === 1 ? ( + <>All doctors are made of alphabetical symbols only :)</> + ) : ( + // eslint-disable-next-line react/jsx-no-useless-fragment + <>{`Nothing to see on page ${page}.`}</> + ) + } + /> + </div> + ); }; export default DoctorsWithNonAlphabeticalSymbols; diff --git a/imports/ui/issues/Issues.tsx b/imports/ui/issues/Issues.tsx index f0b3dbe70..d48ed2627 100644 --- a/imports/ui/issues/Issues.tsx +++ b/imports/ui/issues/Issues.tsx @@ -1,8 +1,16 @@ import React from 'react'; + +import {Route, Routes, useParams} from 'react-router-dom'; + import {styled} from '@mui/material/styles'; import Typography from '@mui/material/Typography'; +import {myEncodeURIComponent} from '../../util/uri'; + +import NoContent from '../navigation/NoContent'; +import TabJumper from '../navigation/TabJumper'; + import ConsultationsMissingPaymentData from './ConsultationsMissingPaymentData'; import ConsultationsMissingABook from './ConsultationsMissingABook'; import ConsultationsWithPriceZeroNotInBookZero from './ConsultationsWithPriceZeroNotInBookZero'; @@ -24,31 +32,129 @@ const Root = styled('div')(({theme}) => ({ }, })); +const tabs = [ + 'uploads-not-attached', + 'documents-not-decoded', + 'documents-not-parsed', + 'documents-not-linked', + 'consultations-no-payment', + 'consultations-no-book', + 'consultations-price-is-zero', + 'doctors-non-alphabetical', +]; + +const IssuesTabs = () => { + const params = useParams<{tab?: string}>(); + return ( + <TabJumper + tabs={tabs} + current={params.tab} + toURL={(x) => `${params.tab ? '../' : ''}${myEncodeURIComponent(x)}`} + /> + ); +}; + const Issues = () => { return ( <Root> - <Typography variant="h3">Uploads that are not attached</Typography> - <UnattachedUploads className={classes.container} /> - <Typography variant="h3">Documents that are not parsed</Typography> - <UnparsedDocuments className={classes.container} /> - <Typography variant="h3"> - Documents that are not properly decoded - </Typography> - <MangledDocuments className={classes.container} /> - <Typography variant="h3">Documents that are not linked</Typography> - <UnlinkedDocuments className={classes.container} /> - <Typography variant="h3">Consultations missing payment data</Typography> - <ConsultationsMissingPaymentData className={classes.container} /> - <Typography variant="h3">Consultations missing a book</Typography> - <ConsultationsMissingABook className={classes.container} /> - <Typography variant="h3"> - Consultations with price 0 not in book 0 - </Typography> - <ConsultationsWithPriceZeroNotInBookZero className={classes.container} /> - <Typography variant="h3"> - Doctors with non alphabetical symbols - </Typography> - <DoctorsWithNonAlphabeticalSymbols className={classes.container} /> + <Routes> + <Route index element={<IssuesTabs />} /> + <Route path=":tab/*" element={<IssuesTabs />} /> + </Routes> + + <Routes> + <Route path="/" element={<NoContent>Select an issue type</NoContent>} /> + <Route + path="uploads-not-attached/*" + element={ + <> + <Typography variant="h3"> + Uploads that are not attached + </Typography> + <UnattachedUploads className={classes.container} /> + </> + } + /> + <Route + path="documents-not-parsed/*" + element={ + <> + <Typography variant="h3"> + Documents that are not parsed + </Typography> + <UnparsedDocuments className={classes.container} /> + </> + } + /> + <Route + path="documents-not-decoded/*" + element={ + <> + <Typography variant="h3"> + Documents that are not properly decoded + </Typography> + <MangledDocuments className={classes.container} /> + </> + } + /> + <Route + path="documents-not-linked/*" + element={ + <> + <Typography variant="h3"> + Documents that are not linked + </Typography> + <UnlinkedDocuments className={classes.container} /> + </> + } + /> + <Route + path="consultations-no-payment/*" + element={ + <> + <Typography variant="h3"> + Consultations missing payment data + </Typography> + <ConsultationsMissingPaymentData className={classes.container} /> + </> + } + /> + <Route + path="consultations-no-book/*" + element={ + <> + <Typography variant="h3">Consultations missing a book</Typography> + <ConsultationsMissingABook className={classes.container} /> + </> + } + /> + <Route + path="consultations-price-is-zero/*" + element={ + <> + <Typography variant="h3"> + Consultations with price 0 not in book 0 + </Typography> + <ConsultationsWithPriceZeroNotInBookZero + className={classes.container} + /> + </> + } + /> + <Route + path="doctors-non-alphabetical/*" + element={ + <> + <Typography variant="h3"> + Doctors with non alphabetical symbols + </Typography> + <DoctorsWithNonAlphabeticalSymbols + className={classes.container} + /> + </> + } + /> + </Routes> </Root> ); }; diff --git a/imports/ui/issues/MangledDocuments.tests.tsx b/imports/ui/issues/MangledDocuments.tests.tsx new file mode 100644 index 000000000..d7aeb8f8d --- /dev/null +++ b/imports/ui/issues/MangledDocuments.tests.tsx @@ -0,0 +1,174 @@ +import {assert} from 'chai'; + +import React from 'react'; + +import {BrowserRouter} from 'react-router-dom'; + +import {list} from '@iterable-iterator/list'; +import {map} from '@iterable-iterator/map'; +import {range} from '@iterable-iterator/range'; + +import {client, randomPassword, randomUserId} from '../../_test/fixtures'; +import {render as _render, waitFor} from '../../_test/react'; + +import createUserWithPasswordAndLogin from '../../api/user/createUserWithPasswordAndLogin'; + +import call from '../../api/endpoint/call'; +import documentsInsert from '../../api/endpoint/documents/insert'; +import documentsSuperdelete from '../../api/endpoint/documents/superdelete'; + +import DateTimeLocalizationProvider from '../i18n/DateTimeLocalizationProvider'; + +import MangledDocuments from './MangledDocuments'; + +const textNoIssues = 'All documents have been decoded :)'; + +const documentLinkName = (_id: string) => `Open document #${_id} in New Tab`; + +const render = (children: React.ReactNode) => + _render(children, { + wrapper: ({children}: {children: React.ReactNode}) => ( + <DateTimeLocalizationProvider> + <BrowserRouter>{children}</BrowserRouter> + </DateTimeLocalizationProvider> + ), + }); + +const iso2022kr = [0x1b, 0x24, 0x29, 0x43]; + +client(__filename, () => { + it('renders when not logged in', async () => { + const {findByText} = render(<MangledDocuments />); + await findByText(textNoIssues); + }); + + it('renders when there are no issues', async () => { + const username = randomUserId(); + const password = randomPassword(); + await createUserWithPasswordAndLogin(username, password); + + const {findByText} = render(<MangledDocuments />); + + await findByText(textNoIssues); + }); + + it('rerenders when a new issue is created', async () => { + const username = randomUserId(); + const password = randomPassword(); + await createUserWithPasswordAndLogin(username, password); + + const {findByRole, findByText} = render(<MangledDocuments />); + + await findByText(textNoIssues); + + const documentIds = await call(documentsInsert, { + array: Uint8Array.from(iso2022kr), + }); + + await Promise.all( + list( + map( + async (documentId: string) => + findByRole('link', {name: documentLinkName(documentId)}), + documentIds, + ), + ), + ); + }); + + it('rerenders when an issue is fixed', async () => { + const username = randomUserId(); + const password = randomPassword(); + await createUserWithPasswordAndLogin(username, password); + + const documentIds = await call(documentsInsert, { + array: Uint8Array.from(iso2022kr), + }); + + const {findByRole, findByText} = render(<MangledDocuments />); + + await Promise.all( + list( + map( + async (documentId: string) => + findByRole('link', {name: documentLinkName(documentId)}), + documentIds, + ), + ), + ); + + await Promise.all( + list( + map( + async (documentId: string) => call(documentsSuperdelete, documentId), + documentIds, + ), + ), + ); + + await findByText(textNoIssues); + }); + + it('paginates beyond 10 issues', async () => { + const {setupUser} = await import('../../../test/app/client/fixtures'); + const username = randomUserId(); + const password = randomPassword(); + await createUserWithPasswordAndLogin(username, password); + + const n = 13; + + await Promise.all( + list( + map( + async (i: number) => + call(documentsInsert, { + array: Uint8Array.from([...iso2022kr, i, i]), + }), + range(n), + ), + ), + ); + + const {findByRole, getAllByRole} = render(<MangledDocuments />); + + const {user} = setupUser(); + + await user.click(await findByRole('link', {name: 'Page 2'})); + + await waitFor(() => { + const listed = getAllByRole('link', { + name: /^open document #[^ ]+ in new tab$/i, + }); + assert.lengthOf(listed, n - 10); + }); + }); + + it('paginates beyond 10 issues (empty)', async () => { + const {setupUser} = await import('../../../test/app/client/fixtures'); + const username = randomUserId(); + const password = randomPassword(); + await createUserWithPasswordAndLogin(username, password); + + const n = 10; + + await Promise.all( + list( + map( + async (i: number) => + call(documentsInsert, { + array: Uint8Array.from([...iso2022kr, i, i]), + }), + range(n), + ), + ), + ); + + const {findByRole, findByText} = render(<MangledDocuments />); + + const {user} = setupUser(); + + await user.click(await findByRole('link', {name: 'Page 2'})); + + await findByText('Nothing to see on page 2.'); + }); +}); diff --git a/imports/ui/issues/MangledDocuments.tsx b/imports/ui/issues/MangledDocuments.tsx index 6850e77f8..ff88ab723 100644 --- a/imports/ui/issues/MangledDocuments.tsx +++ b/imports/ui/issues/MangledDocuments.tsx @@ -2,33 +2,34 @@ import React from 'react'; import {useMangledDocuments} from '../../api/issues'; -import DocumentsPage from '../documents/DocumentsPage'; +import paged from '../routes/paged'; -const MangledDocuments = (props) => { - const options = { - sort: { - createdAt: 1, - }, - fields: { - ...DocumentsPage.projection, - // encoding: 1, - // createdAt: 1 - }, - }; +import makeDocumentsList from '../documents/makeDocumentsList'; - const {loading, results: documents} = useMangledDocuments({}, options, []); +const DocumentsPage = makeDocumentsList(useMangledDocuments); +const DocumentsPager = paged(DocumentsPage); - if (loading) { - return <div {...props}>Loading...</div>; - } - - if (documents.length === 0) { - return <div {...props}>All documents have been decoded :)</div>; - } +type Props = React.JSX.IntrinsicAttributes & + React.ClassAttributes<HTMLDivElement> & + React.HTMLAttributes<HTMLDivElement>; +const MangledDocuments = (props: Props) => { return ( <div {...props}> - <DocumentsPage documents={documents} /> + <DocumentsPager + sort={{ + createdAt: 1, + }} + LoadingIndicator={(_: {}) => <>Loading...</>} + EmptyPage={({page}: {readonly page: number}) => + page === 1 ? ( + <>All documents have been decoded :)</> + ) : ( + // eslint-disable-next-line react/jsx-no-useless-fragment + <>{`Nothing to see on page ${page}.`}</> + ) + } + /> </div> ); }; diff --git a/imports/ui/issues/PatientsMissingABirthdate.tsx b/imports/ui/issues/PatientsMissingABirthdate.tsx index 0b0b3b7e6..28235cde5 100644 --- a/imports/ui/issues/PatientsMissingABirthdate.tsx +++ b/imports/ui/issues/PatientsMissingABirthdate.tsx @@ -6,9 +6,19 @@ import PatientGridItem from '../patients/PatientGridItem'; import StaticPatientCard from '../patients/StaticPatientCard'; import {usePatientsMissingABirthdate} from '../../api/issues'; - -const PatientsMissingABirthdate = (props) => { - const {loading, results: patients} = usePatientsMissingABirthdate(); +import type PropsOf from '../../util/types/PropsOf'; + +type DivProps = React.JSX.IntrinsicAttributes & + React.ClassAttributes<HTMLDivElement> & + React.HTMLAttributes<HTMLDivElement>; +type GridProps = PropsOf<typeof Grid>; +type Props = DivProps & GridProps; + +const PatientsMissingABirthdate = (props: Props) => { + const {loading, results: patients} = usePatientsMissingABirthdate( + {filter: {}}, + [], + ); if (loading) { return <div {...props}>Loading...</div>; diff --git a/imports/ui/issues/PatientsMissingAGender.tsx b/imports/ui/issues/PatientsMissingAGender.tsx index 72a31b460..9667eaf1c 100644 --- a/imports/ui/issues/PatientsMissingAGender.tsx +++ b/imports/ui/issues/PatientsMissingAGender.tsx @@ -6,9 +6,19 @@ import PatientGridItem from '../patients/PatientGridItem'; import StaticPatientCard from '../patients/StaticPatientCard'; import {usePatientsMissingAGender} from '../../api/issues'; - -const PatientsMissingAGender = (props) => { - const {loading, results: patients} = usePatientsMissingAGender(); +import type PropsOf from '../../util/types/PropsOf'; + +type DivProps = React.JSX.IntrinsicAttributes & + React.ClassAttributes<HTMLDivElement> & + React.HTMLAttributes<HTMLDivElement>; +type GridProps = PropsOf<typeof Grid>; +type Props = DivProps & GridProps; + +const PatientsMissingAGender = (props: Props) => { + const {loading, results: patients} = usePatientsMissingAGender( + {filter: {}}, + [], + ); if (loading) { return <div {...props}>Loading...</div>; diff --git a/imports/ui/issues/UnattachedUploads.tests.tsx b/imports/ui/issues/UnattachedUploads.tests.tsx new file mode 100644 index 000000000..d5b5a26e6 --- /dev/null +++ b/imports/ui/issues/UnattachedUploads.tests.tsx @@ -0,0 +1,214 @@ +import {assert} from 'chai'; + +import React from 'react'; + +import {BrowserRouter} from 'react-router-dom'; + +import {faker} from '@faker-js/faker'; + +import {map} from '@iterable-iterator/map'; +import {range} from '@iterable-iterator/range'; + +import {client, randomPassword, randomUserId} from '../../_test/fixtures'; +import {render as _render, waitFor} from '../../_test/react'; + +import createUserWithPasswordAndLogin from '../../api/user/createUserWithPasswordAndLogin'; + +import {newUpload} from '../../api/_dev/populate/uploads'; +import {newPatientFormData} from '../../api/_dev/populate/patients'; +import {newConsultationFormData} from '../../api/_dev/populate/consultations'; + +import call from '../../api/endpoint/call'; +import patientsInsert from '../../api/endpoint/patients/insert'; +import attachToPatient from '../../api/endpoint/patients/attach'; +import detachFromPatient from '../../api/endpoint/patients/detach'; +import consultationsInsert from '../../api/endpoint/consultations/insert'; +import attachToConsultation from '../../api/endpoint/consultations/attach'; +import detachFromConsultation from '../../api/endpoint/consultations/detach'; + +import DateTimeLocalizationProvider from '../i18n/DateTimeLocalizationProvider'; + +import UnattachedUploads from './UnattachedUploads'; + +const textNoIssues = 'All uploads are attached to something :)'; + +const render = (children: React.ReactNode) => + _render(children, { + wrapper: ({children}: {children: React.ReactNode}) => ( + <DateTimeLocalizationProvider> + <BrowserRouter>{children}</BrowserRouter> + </DateTimeLocalizationProvider> + ), + }); + +client(__filename, () => { + it('renders when not logged in', async () => { + const {findByText} = render(<UnattachedUploads />); + await findByText(textNoIssues); + }); + + it('renders when there are no issues', async () => { + const username = randomUserId(); + const password = randomPassword(); + await createUserWithPasswordAndLogin(username, password); + + const {findByText} = render(<UnattachedUploads />); + + await findByText(textNoIssues); + }); + + it('rerenders when a new issue is created', async () => { + const username = randomUserId(); + const password = randomPassword(); + await createUserWithPasswordAndLogin(username, password); + + const {findByRole, findByText} = render(<UnattachedUploads />); + + await findByText(textNoIssues); + + const name = faker.string.uuid(); + + await newUpload(undefined, {name}); + + await findByRole('img', {name}); + }); + + it('rerenders when an upload is attached to a patient', async () => { + const username = randomUserId(); + const password = randomPassword(); + await createUserWithPasswordAndLogin(username, password); + + const name = faker.string.uuid(); + + const patientId = await call(patientsInsert, newPatientFormData()); + + const {_id: uploadId} = await newUpload(undefined, {name}); + + const {findByRole, findByText} = render(<UnattachedUploads />); + + await findByRole('img', {name}); + + await call(attachToPatient, patientId, uploadId); + + await findByText(textNoIssues); + }); + + it('rerenders when an upload is attached to a consultation', async () => { + const username = randomUserId(); + const password = randomPassword(); + await createUserWithPasswordAndLogin(username, password); + + const name = faker.string.uuid(); + + const patientId = await call(patientsInsert, newPatientFormData()); + const {insertedId: consultationId} = await call( + consultationsInsert, + newConsultationFormData({patientId}), + ); + + const {_id: uploadId} = await newUpload(undefined, {name}); + + const {findByRole, findByText} = render(<UnattachedUploads />); + + await findByRole('img', {name}); + + await call(attachToConsultation, consultationId, uploadId); + + await findByText(textNoIssues); + }); + + it('rerenders when an upload is detached from a patient', async () => { + const username = randomUserId(); + const password = randomPassword(); + await createUserWithPasswordAndLogin(username, password); + + const name = faker.string.uuid(); + + const patientId = await call(patientsInsert, newPatientFormData()); + const {_id: uploadId} = await newUpload(undefined, {name}); + await call(attachToPatient, patientId, uploadId); + + const {findByRole, findByText} = render(<UnattachedUploads />); + + await findByText(textNoIssues); + + await call(detachFromPatient, patientId, uploadId); + + await findByRole('img', {name}); + }); + + it('rerenders when an upload is detached from a consultation', async () => { + const username = randomUserId(); + const password = randomPassword(); + await createUserWithPasswordAndLogin(username, password); + + const name = faker.string.uuid(); + + const patientId = await call(patientsInsert, newPatientFormData()); + const {insertedId: consultationId} = await call( + consultationsInsert, + newConsultationFormData({patientId}), + ); + const {_id: uploadId} = await newUpload(undefined, {name}); + await call(attachToConsultation, consultationId, uploadId); + + const {findByRole, findByText} = render(<UnattachedUploads />); + + await findByText(textNoIssues); + + await call(detachFromConsultation, consultationId, uploadId); + + await findByRole('img', {name}); + }); + + it('paginates beyond 10 issues', async () => { + const {setupUser} = await import('../../../test/app/client/fixtures'); + const username = randomUserId(); + const password = randomPassword(); + await createUserWithPasswordAndLogin(username, password); + + const n = 13; + + await Promise.all( + map(async (i: number) => { + const name = `img-${i}.png`; + return newUpload(undefined, {name}); + }, range(n)), + ); + + const {findByRole, getAllByRole} = render(<UnattachedUploads />); + + const {user} = setupUser(); + + await user.click(await findByRole('link', {name: 'Page 2'})); + + await waitFor(() => { + const listed = getAllByRole('img', {name: /^img-\d+\.png$/}); + assert.lengthOf(listed, n - 10); + }); + }); + + it('paginates beyond 10 issues (empty)', async () => { + const {setupUser} = await import('../../../test/app/client/fixtures'); + const username = randomUserId(); + const password = randomPassword(); + await createUserWithPasswordAndLogin(username, password); + + const n = 10; + + await Promise.all( + map(async (i: number) => { + const name = `img-${i}.png`; + return newUpload(undefined, {name}); + }, range(n)), + ); + + const {findByRole, findByText} = render(<UnattachedUploads />); + + const {user} = setupUser(); + + await user.click(await findByRole('link', {name: 'Page 2'})); + + await findByText('Nothing to see on page 2.'); + }); +}); diff --git a/imports/ui/issues/UnattachedUploads.tsx b/imports/ui/issues/UnattachedUploads.tsx index 1c27919a4..0eaeda7dd 100644 --- a/imports/ui/issues/UnattachedUploads.tsx +++ b/imports/ui/issues/UnattachedUploads.tsx @@ -1,31 +1,35 @@ import React from 'react'; -import useAttachments from '../attachments/useAttachments'; -import AttachmentsGrid from '../attachments/AttachmentsGrid'; +import {useUnattachedUploads} from '../../api/issues'; -const UnattachedUploads = (props) => { - const query = { - filter: { - $and: [ - {'meta.attachedToPatients': {$not: {$gt: ''}}}, - {'meta.attachedToConsultations': {$not: {$gt: ''}}}, - ], - }, - }; +import paged from '../routes/paged'; - const {loading, results: attachments} = useAttachments(query, []); +import makeAttachmentsPage from '../attachments/makeAttachmentsPage'; - if (loading) { - return <div {...props}>Loading...</div>; - } +const UploadsPage = makeAttachmentsPage(useUnattachedUploads); +const UploadsPager = paged(UploadsPage); - if (attachments.length === 0) { - return <div {...props}>All uploads are attached to something :)</div>; - } +type Props = React.JSX.IntrinsicAttributes & + React.ClassAttributes<HTMLDivElement> & + React.HTMLAttributes<HTMLDivElement>; +const UnattachedUploads = (props: Props) => { return ( <div {...props}> - <AttachmentsGrid spacing={2} attachments={attachments} /> + <UploadsPager + sort={{ + updatedAt: -1, + }} + LoadingIndicator={(_: {}) => <>Loading...</>} + EmptyPage={({page}: {readonly page: number}) => + page === 1 ? ( + <>All uploads are attached to something :)</> + ) : ( + // eslint-disable-next-line react/jsx-no-useless-fragment + <>{`Nothing to see on page ${page}.`}</> + ) + } + /> </div> ); }; diff --git a/imports/ui/issues/UnlinkedDocuments.tests.tsx b/imports/ui/issues/UnlinkedDocuments.tests.tsx new file mode 100644 index 000000000..f35216fbc --- /dev/null +++ b/imports/ui/issues/UnlinkedDocuments.tests.tsx @@ -0,0 +1,187 @@ +import {assert} from 'chai'; + +import React from 'react'; + +import {BrowserRouter} from 'react-router-dom'; + +import {list} from '@iterable-iterator/list'; +import {map} from '@iterable-iterator/map'; +import {sum} from '@iterable-iterator/reduce'; +import {len} from '@functional-abstraction/operator'; + +import {client, randomPassword, randomUserId} from '../../_test/fixtures'; +import {render as _render, waitFor} from '../../_test/react'; + +import createUserWithPasswordAndLogin from '../../api/user/createUserWithPasswordAndLogin'; + +import {newPatientFormData} from '../../api/_dev/populate/patients'; +import { + exampleHealthoneLab, + exampleMedidocReport, + exampleHealthoneReport, +} from '../../api/_dev/populate/documents'; + +import call from '../../api/endpoint/call'; +import patientsInsert from '../../api/endpoint/patients/insert'; +import documentsInsert from '../../api/endpoint/documents/insert'; +import documentsLink from '../../api/endpoint/documents/link'; + +import DateTimeLocalizationProvider from '../i18n/DateTimeLocalizationProvider'; + +import UnlinkedDocuments from './UnlinkedDocuments'; + +const textNoIssues = 'All documents have an assigned patient :)'; + +const documentLinkName = (_id: string) => `Open document #${_id} in New Tab`; + +const render = (children: React.ReactNode) => + _render(children, { + wrapper: ({children}: {children: React.ReactNode}) => ( + <DateTimeLocalizationProvider> + <BrowserRouter>{children}</BrowserRouter> + </DateTimeLocalizationProvider> + ), + }); + +client(__filename, () => { + it('renders when not logged in', async () => { + const {findByText} = render(<UnlinkedDocuments />); + await findByText(textNoIssues); + }); + + it('renders when there are no issues', async () => { + const username = randomUserId(); + const password = randomPassword(); + await createUserWithPasswordAndLogin(username, password); + + const {findByText} = render(<UnlinkedDocuments />); + + await findByText(textNoIssues); + }); + + it('rerenders when a new issue is created', async () => { + const username = randomUserId(); + const password = randomPassword(); + await createUserWithPasswordAndLogin(username, password); + + const {findByRole, findByText} = render(<UnlinkedDocuments />); + + await findByText(textNoIssues); + + const documentIds = await call(documentsInsert, { + array: new TextEncoder().encode(exampleMedidocReport.contents), + }); + + await Promise.all( + list( + map( + async (documentId: string) => + findByRole('link', {name: documentLinkName(documentId)}), + documentIds, + ), + ), + ); + }); + + it('rerenders when an issue is fixed', async () => { + const username = randomUserId(); + const password = randomPassword(); + await createUserWithPasswordAndLogin(username, password); + + const documentIds = await call(documentsInsert, { + array: new TextEncoder().encode(exampleMedidocReport.contents), + }); + + const {findByRole, findByText} = render(<UnlinkedDocuments />); + + await Promise.all( + list( + map( + async (documentId: string) => + findByRole('link', {name: documentLinkName(documentId)}), + documentIds, + ), + ), + ); + + const patient = newPatientFormData(); + const patientId = await call(patientsInsert, patient); + + await Promise.all( + list( + map( + async (documentId: string) => + call(documentsLink, documentId, patientId), + documentIds, + ), + ), + ); + + await findByText(textNoIssues); + }); + + it('paginates beyond 10 issues', async () => { + const {setupUser} = await import('../../../test/app/client/fixtures'); + const username = randomUserId(); + const password = randomPassword(); + await createUserWithPasswordAndLogin(username, password); + + const inserted = await Promise.all( + list( + map( + async ({contents}) => + call(documentsInsert, { + array: new TextEncoder().encode(contents), + }), + [exampleHealthoneReport, exampleHealthoneLab, exampleMedidocReport], + ), + ), + ); + + const n = sum(map(len, inserted)); + assert.strictEqual(n, 11); + + const {findByRole, getAllByRole} = render(<UnlinkedDocuments />); + + const {user} = setupUser(); + + await user.click(await findByRole('link', {name: 'Page 2'})); + + await waitFor(() => { + const listed = getAllByRole('link', { + name: /^open document #[^ ]+ in new tab$/i, + }); + assert.lengthOf(listed, n - 10); + }); + }); + + it('paginates beyond 10 issues (empty)', async () => { + const {setupUser} = await import('../../../test/app/client/fixtures'); + const username = randomUserId(); + const password = randomPassword(); + await createUserWithPasswordAndLogin(username, password); + + const inserted = await Promise.all( + list( + map( + async ({contents}) => + call(documentsInsert, { + array: new TextEncoder().encode(contents), + }), + [exampleHealthoneReport, exampleHealthoneLab], + ), + ), + ); + + const n = sum(map(len, inserted)); + assert.strictEqual(n, 10); + + const {findByRole, findByText} = render(<UnlinkedDocuments />); + + const {user} = setupUser(); + + await user.click(await findByRole('link', {name: 'Page 2'})); + + await findByText('Nothing to see on page 2.'); + }); +}); diff --git a/imports/ui/issues/UnlinkedDocuments.tsx b/imports/ui/issues/UnlinkedDocuments.tsx index 7afe71d33..908b6cb3c 100644 --- a/imports/ui/issues/UnlinkedDocuments.tsx +++ b/imports/ui/issues/UnlinkedDocuments.tsx @@ -2,38 +2,37 @@ import React from 'react'; import {useUnlinkedDocuments} from '../../api/issues'; -import DocumentsPage from '../documents/DocumentsPage'; +import paged from '../routes/paged'; -const UnlinkedDocuments = (props) => { - const options = { - sort: { - 'patient.lastname': 1, - 'patient.firstname': 1, - datetime: 1, - createdAt: 1, - }, - fields: { - ...DocumentsPage.projection, - // patientId: 1, - // patient: 1, - // datetime: 1, - // createdAt: 1 - }, - }; +import makeDocumentsList from '../documents/makeDocumentsList'; - const {loading, results: documents} = useUnlinkedDocuments({}, options, []); +const DocumentsPage = makeDocumentsList(useUnlinkedDocuments); +const DocumentsPager = paged(DocumentsPage); - if (loading) { - return <div {...props}>Loading...</div>; - } - - if (documents.length === 0) { - return <div {...props}>All documents have an assigned patient :)</div>; - } +type Props = React.JSX.IntrinsicAttributes & + React.ClassAttributes<HTMLDivElement> & + React.HTMLAttributes<HTMLDivElement>; +const UnlinkedDocuments = (props: Props) => { return ( <div {...props}> - <DocumentsPage documents={documents} /> + <DocumentsPager + sort={{ + 'patient.lastname': 1, + 'patient.firstname': 1, + datetime: 1, + createdAt: 1, + }} + LoadingIndicator={(_: {}) => <>Loading...</>} + EmptyPage={({page}: {readonly page: number}) => + page === 1 ? ( + <>All documents have an assigned patient :)</> + ) : ( + // eslint-disable-next-line react/jsx-no-useless-fragment + <>{`Nothing to see on page ${page}.`}</> + ) + } + /> </div> ); }; diff --git a/imports/ui/issues/UnparsedDocuments.tests.tsx b/imports/ui/issues/UnparsedDocuments.tests.tsx new file mode 100644 index 000000000..b245f7024 --- /dev/null +++ b/imports/ui/issues/UnparsedDocuments.tests.tsx @@ -0,0 +1,172 @@ +import {assert} from 'chai'; + +import React from 'react'; + +import {BrowserRouter} from 'react-router-dom'; + +import {list} from '@iterable-iterator/list'; +import {map} from '@iterable-iterator/map'; +import {range} from '@iterable-iterator/range'; + +import {client, randomPassword, randomUserId} from '../../_test/fixtures'; +import {render as _render, waitFor} from '../../_test/react'; + +import createUserWithPasswordAndLogin from '../../api/user/createUserWithPasswordAndLogin'; + +import call from '../../api/endpoint/call'; +import documentsInsert from '../../api/endpoint/documents/insert'; +import documentsSuperdelete from '../../api/endpoint/documents/superdelete'; + +import DateTimeLocalizationProvider from '../i18n/DateTimeLocalizationProvider'; + +import UnparsedDocuments from './UnparsedDocuments'; + +const textNoIssues = 'All documents have been parsed :)'; + +const documentLinkName = (_id: string) => `Open document #${_id} in New Tab`; + +const render = (children: React.ReactNode) => + _render(children, { + wrapper: ({children}: {children: React.ReactNode}) => ( + <DateTimeLocalizationProvider> + <BrowserRouter>{children}</BrowserRouter> + </DateTimeLocalizationProvider> + ), + }); + +client(__filename, () => { + it('renders when not logged in', async () => { + const {findByText} = render(<UnparsedDocuments />); + await findByText(textNoIssues); + }); + + it('renders when there are no issues', async () => { + const username = randomUserId(); + const password = randomPassword(); + await createUserWithPasswordAndLogin(username, password); + + const {findByText} = render(<UnparsedDocuments />); + + await findByText(textNoIssues); + }); + + it('rerenders when a new issue is created', async () => { + const username = randomUserId(); + const password = randomPassword(); + await createUserWithPasswordAndLogin(username, password); + + const {findByRole, findByText} = render(<UnparsedDocuments />); + + await findByText(textNoIssues); + + const documentIds = await call(documentsInsert, { + array: new TextEncoder().encode('ABRACADABRA'), + }); + + await Promise.all( + list( + map( + async (documentId: string) => + findByRole('link', {name: documentLinkName(documentId)}), + documentIds, + ), + ), + ); + }); + + it('rerenders when an issue is fixed', async () => { + const username = randomUserId(); + const password = randomPassword(); + await createUserWithPasswordAndLogin(username, password); + + const documentIds = await call(documentsInsert, { + array: new TextEncoder().encode('ABRACADABRA'), + }); + + const {findByRole, findByText} = render(<UnparsedDocuments />); + + await Promise.all( + list( + map( + async (documentId: string) => + findByRole('link', {name: documentLinkName(documentId)}), + documentIds, + ), + ), + ); + + await Promise.all( + list( + map( + async (documentId: string) => call(documentsSuperdelete, documentId), + documentIds, + ), + ), + ); + + await findByText(textNoIssues); + }); + + it('paginates beyond 10 issues', async () => { + const {setupUser} = await import('../../../test/app/client/fixtures'); + const username = randomUserId(); + const password = randomPassword(); + await createUserWithPasswordAndLogin(username, password); + + const n = 13; + + await Promise.all( + list( + map( + async (i: number) => + call(documentsInsert, { + array: new TextEncoder().encode(`ABRACADABRA-${i}`), + }), + range(n), + ), + ), + ); + + const {findByRole, getAllByRole} = render(<UnparsedDocuments />); + + const {user} = setupUser(); + + await user.click(await findByRole('link', {name: 'Page 2'})); + + await waitFor(() => { + const listed = getAllByRole('link', { + name: /^open document #[^ ]+ in new tab$/i, + }); + assert.lengthOf(listed, n - 10); + }); + }); + + it('paginates beyond 10 issues (empty)', async () => { + const {setupUser} = await import('../../../test/app/client/fixtures'); + const username = randomUserId(); + const password = randomPassword(); + await createUserWithPasswordAndLogin(username, password); + + const n = 10; + + await Promise.all( + list( + map( + async (i: number) => + call(documentsInsert, { + array: new TextEncoder().encode(`ABRACADABRA-${i}`), + }), + range(n), + ), + ), + ); + + const {findByRole, findByText} = render(<UnparsedDocuments />); + + const {user} = setupUser(); + + await user.click(await findByRole('link', {name: 'Page 2'})); + + await findByText('Nothing to see on page 2.'); + }); +}); diff --git a/imports/ui/issues/UnparsedDocuments.tsx b/imports/ui/issues/UnparsedDocuments.tsx index 4052fe847..8953e0c41 100644 --- a/imports/ui/issues/UnparsedDocuments.tsx +++ b/imports/ui/issues/UnparsedDocuments.tsx @@ -2,33 +2,34 @@ import React from 'react'; import {useUnparsedDocuments} from '../../api/issues'; -import DocumentsPage from '../documents/DocumentsPage'; +import paged from '../routes/paged'; -const UnparsedDocuments = (props) => { - const options = { - sort: { - createdAt: 1, - }, - fields: { - ...DocumentsPage.projection, - // parsed: 1, - // createdAt: 1 - }, - }; +import makeDocumentsList from '../documents/makeDocumentsList'; - const {loading, results: documents} = useUnparsedDocuments({}, options, []); +const DocumentsPage = makeDocumentsList(useUnparsedDocuments); +const DocumentsPager = paged(DocumentsPage); - if (loading) { - return <div {...props}>Loading...</div>; - } - - if (documents.length === 0) { - return <div {...props}>All documents have been parsed :)</div>; - } +type Props = React.JSX.IntrinsicAttributes & + React.ClassAttributes<HTMLDivElement> & + React.HTMLAttributes<HTMLDivElement>; +const UnparsedDocuments = (props: Props) => { return ( <div {...props}> - <DocumentsPage documents={documents} /> + <DocumentsPager + sort={{ + createdAt: 1, + }} + LoadingIndicator={(_: {}) => <>Loading...</>} + EmptyPage={({page}: {readonly page: number}) => + page === 1 ? ( + <>All documents have been parsed :)</> + ) : ( + // eslint-disable-next-line react/jsx-no-useless-fragment + <>{`Nothing to see on page ${page}.`}</> + ) + } + /> </div> ); }; diff --git a/imports/ui/navigation/Paginator.tsx b/imports/ui/navigation/Paginator.tsx index 95508c201..0f33dd0fa 100644 --- a/imports/ui/navigation/Paginator.tsx +++ b/imports/ui/navigation/Paginator.tsx @@ -34,17 +34,24 @@ const PaginatorBase = ({ } }, [loading, isOnlyPage]); + const prevPage = page - 1; + const nextPage = page + 1; + const prevDisabled = disabled || isFirstPage; + const nextDisabled = disabled || (!loading && end); + return ( <> <Prev visible={!hide} - to={`${root}${page - 1}`} - disabled={disabled || isFirstPage} + to={`${root}${prevPage}`} + disabled={prevDisabled} + aria-label={prevDisabled ? undefined : `Page ${prevPage}`} /> <Next visible={!hide} - to={`${root}${page + 1}`} - disabled={disabled || (!loading && end)} + to={`${root}${nextPage}`} + disabled={nextDisabled} + aria-label={nextDisabled ? undefined : `Page ${nextPage}`} /> </> ); diff --git a/imports/ui/routes/paged.tsx b/imports/ui/routes/paged.tsx index a128ee144..0d6f84833 100644 --- a/imports/ui/routes/paged.tsx +++ b/imports/ui/routes/paged.tsx @@ -3,6 +3,7 @@ import React from 'react'; import {useParams} from 'react-router-dom'; import {parseNonNegativeIntegerStrictOrUndefined} from '../../api/string'; +import type PropsOf from '../../util/types/PropsOf'; import branch, {type BranchProps} from './branch'; @@ -22,6 +23,10 @@ const PagedRoute = <C extends React.ElementType>({ return <Component page={page} {...props} />; }; -const paged = branch(['*', 'page/:page/*'], PagedRoute); +const paged = branch(['*', 'page/:page/*'], PagedRoute) as < + C extends React.ElementType, +>( + component: C, +) => (props: PropsOf<C>) => React.ReactElement; export default paged; diff --git a/imports/ui/search/FullTextSearchResultsRoutes.tsx b/imports/ui/search/FullTextSearchResultsRoutes.tsx index ff0376f19..369b86141 100644 --- a/imports/ui/search/FullTextSearchResultsRoutes.tsx +++ b/imports/ui/search/FullTextSearchResultsRoutes.tsx @@ -40,7 +40,7 @@ const FullTextSearchResults = () => { const [key, refresh] = useRandom(); const {query: rawQuery} = useParams<Params>(); const query = myDecodeURIComponent(rawQuery); - const deferredQuery = useDeferredValue(query); + const deferredQuery = useDeferredValue(query ?? ''); return ( <Root className={classes.root}> <Typography className={classes.heading} variant="h3"> diff --git a/imports/ui/tags/TagListPage.tsx b/imports/ui/tags/TagListPage.tsx index 70be5566e..48b9eab3a 100644 --- a/imports/ui/tags/TagListPage.tsx +++ b/imports/ui/tags/TagListPage.tsx @@ -23,8 +23,16 @@ export type TagListPageProps<T extends TagNameFields & TagMetadata> = { readonly sort?: Sort<T>; readonly useTags: GenericQueryHook<T>; + + readonly LoadingIndicator?: React.ElementType<{}>; + readonly EmptyPage?: React.ElementType<{page: number}>; }; +const DefaultLoadingIndicator = Loading; +const DefaultEmptyPage = ({page}: {readonly page: number}) => ( + <NoContent>{`Nothing to see on page ${page}.`}</NoContent> +); + const TagListPage = <T extends TagNameFields & TagMetadata>({ useTags, filter = {}, @@ -32,6 +40,8 @@ const TagListPage = <T extends TagNameFields & TagMetadata>({ page = 1, perpage = 10, Card, + LoadingIndicator = DefaultLoadingIndicator, + EmptyPage = DefaultEmptyPage, }: TagListPageProps<T>) => { const [key, refresh] = useRandom(); const query = { @@ -53,9 +63,9 @@ const TagListPage = <T extends TagNameFields & TagMetadata>({ <div style={style}> {tags.length === 0 ? ( loading ? ( - <Loading /> + <LoadingIndicator /> ) : ( - <NoContent>{`Nothing to see on page ${page}.`}</NoContent> + <EmptyPage page={page} /> ) ) : ( <TagGrid Card={Card} tags={tags} /> diff --git a/imports/util/uri.ts b/imports/util/uri.ts index 87785c7d4..5bd77a444 100644 --- a/imports/util/uri.ts +++ b/imports/util/uri.ts @@ -2,8 +2,9 @@ export const myEncodeURIComponent = (component: string): string => { return encodeURIComponent(component); }; -export const myDecodeURIComponent = ( - component: string | undefined, -): string | undefined => { +export function myDecodeURIComponent<T extends string | undefined>( + component: T, +): T; +export function myDecodeURIComponent(component: string | undefined) { return component === undefined ? undefined : decodeURIComponent(component); -}; +} diff --git a/lib/test/fixtures/_regenerator-runtime.tests.ts b/lib/test/fixtures/client/polyfill.app-tests.ts similarity index 57% rename from lib/test/fixtures/_regenerator-runtime.tests.ts rename to lib/test/fixtures/client/polyfill.app-tests.ts index 1a15fb5c6..ba17280c0 100644 --- a/lib/test/fixtures/_regenerator-runtime.tests.ts +++ b/lib/test/fixtures/client/polyfill.app-tests.ts @@ -1,2 +1,2 @@ // eslint-disable-next-line import/no-unassigned-import -import 'regenerator-runtime/runtime.js'; +import '../../../../client/polyfill'; diff --git a/lib/test/fixtures/_regenerator-runtime.app-tests.ts b/lib/test/fixtures/client/polyfill.tests.ts similarity index 57% rename from lib/test/fixtures/_regenerator-runtime.app-tests.ts rename to lib/test/fixtures/client/polyfill.tests.ts index 1a15fb5c6..ba17280c0 100644 --- a/lib/test/fixtures/_regenerator-runtime.app-tests.ts +++ b/lib/test/fixtures/client/polyfill.tests.ts @@ -1,2 +1,2 @@ // eslint-disable-next-line import/no-unassigned-import -import 'regenerator-runtime/runtime.js'; +import '../../../../client/polyfill'; diff --git a/lib/test/fixtures/server/polyfill.app-tests.ts b/lib/test/fixtures/server/polyfill.app-tests.ts new file mode 100644 index 000000000..eaa26008c --- /dev/null +++ b/lib/test/fixtures/server/polyfill.app-tests.ts @@ -0,0 +1,2 @@ +// eslint-disable-next-line import/no-unassigned-import +import '../../../../server/polyfill'; diff --git a/lib/test/fixtures/server/polyfill.tests.ts b/lib/test/fixtures/server/polyfill.tests.ts new file mode 100644 index 000000000..eaa26008c --- /dev/null +++ b/lib/test/fixtures/server/polyfill.tests.ts @@ -0,0 +1,2 @@ +// eslint-disable-next-line import/no-unassigned-import +import '../../../../server/polyfill'; diff --git a/test/README.md b/test/README.md index 76460e707..ac1843fcc 100644 --- a/test/README.md +++ b/test/README.md @@ -172,15 +172,15 @@ - [ ] Delete - [ ] Deleting insurance removes it from all patients - - [ ] Issues - - [ ] It lists uploads that are not attached - - [ ] It lists documents that are not parsed - - [ ] It lists documents that are not decoded - - [ ] It lists documents that are not linked - - [ ] It lists consultations missing payment data - - [ ] It lists consultations missing a book - - [ ] It lists consultations with price 0 not in book 0 - - [ ] It lists doctors with non-alphabetical symbols + - [x] Issues + - [x] It lists uploads that are not attached (~`integration`~) + - [x] It lists documents that are not parsed (~`integration`~) + - [x] It lists documents that are not decoded (~`integration`~) + - [x] It lists documents that are not linked (~`integration`~) + - [x] It lists consultations missing payment data (~`integration`~) + - [x] It lists consultations missing a book (~`integration`~) + - [x] It lists consultations with price 0 not in book 0 (~`integration`~) + - [x] It lists doctors with non-alphabetical symbols (~`integration`~) - [ ] Settings - [ ] UI @@ -189,13 +189,14 @@ - [ ] By clicking hamburger menu - [ ] Theme - [ ] Toggle theme light/dark - - [ ] Set theme primary color - - [ ] Reset theme primary color - - [ ] Set theme secondary color - - [ ] Reset theme secondary color + - [x] Set theme primary color (~`integration`~) + - [x] Reset theme primary color (~`integration`~) + - [x] Set theme secondary color (~`integration`~) + - [x] Reset theme secondary color (~`integration`~) - [ ] Payment - - [ ] Set account holder - - [ ] Set IBAN, check that it validates + - [x] Set account holder (~`integration`~) + - [x] Set IBAN (~`integration`~) + - [ ] Check IBAN validation - [ ] Check only EUR is supported - [ ] Locale - [ ] Changing language updates first day of the week and first week diff --git a/test/app/client/fixtures.ts b/test/app/client/fixtures.ts index 7abfc734a..c13678543 100644 --- a/test/app/client/fixtures.ts +++ b/test/app/client/fixtures.ts @@ -69,7 +69,16 @@ const flakyCloseModals = async ({user}: {user: UserEvent}) => { }; export const createUserWithPasswordAndLogin = async ( - {queryByRole, getByRole, findByRole, getByLabelText, user}: App, + { + queryByRole, + getByRole, + findByRole, + getByLabelText, + user, + }: Pick< + App, + 'queryByRole' | 'getByRole' | 'findByRole' | 'getByLabelText' | 'user' + >, username: string, password: string, ) => { @@ -95,12 +104,13 @@ export const createUserWithPasswordAndLogin = async ( } console.debug('Waiting for "Logged in as ..." button to appear'); - await findByRole( + const loggedInButton = await findByRole( 'button', {name: `Logged in as ${username}`}, {timeout: 5000}, ); console.debug('User successfully created and logged in'); + return loggedInButton; }; export const logout = async ({findByRole, user}: App) => { diff --git a/test/app/client/navigate.app-tests.ts b/test/app/client/navigate.app-tests.ts index 397e9d644..39597ef7e 100644 --- a/test/app/client/navigate.app-tests.ts +++ b/test/app/client/navigate.app-tests.ts @@ -37,6 +37,55 @@ client(__filename, () => { await navigateTo(app, 'Month', /^\/calendar\/month\//); }); + it('should allow to reach /issues/* tabs', async () => { + const username = randomUserId(); + const password = randomPassword(); + const app = setupApp(); + await createUserWithPasswordAndLogin(username, password); + + await navigateTo(app, 'Issues', '/issues'); + await navigateTo( + app, + 'uploads-not-attached', + '/issues/uploads-not-attached', + ); + await navigateTo( + app, + 'documents-not-decoded', + '/issues/documents-not-decoded', + ); + await navigateTo( + app, + 'documents-not-parsed', + '/issues/documents-not-parsed', + ); + await navigateTo( + app, + 'documents-not-linked', + '/issues/documents-not-linked', + ); + await navigateTo( + app, + 'consultations-no-payment', + '/issues/consultations-no-payment', + ); + await navigateTo( + app, + 'consultations-no-book', + '/issues/consultations-no-book', + ); + await navigateTo( + app, + 'consultations-price-is-zero', + '/issues/consultations-price-is-zero', + ); + await navigateTo( + app, + 'doctors-non-alphabetical', + '/issues/doctors-non-alphabetical', + ); + }); + it('should allow to reach /settings/* tabs', async () => { const username = randomUserId(); const password = randomPassword();