Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

A big collection of fixes related to translations and TanStack Query, and packages update. #130

Merged
merged 4 commits into from
Oct 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,13 @@
"rules": {
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/no-empty-interface": "off",
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-namespace": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"@typescript-eslint/no-unused-vars": "warn",
"@typescript-eslint/no-unused-vars": [
qelo marked this conversation as resolved.
Show resolved Hide resolved
"warn",
{ "varsIgnorePattern": "^_" }
],
"no-console": ["warn", { "allow": ["warn", "error"] }],
"no-empty": "off",
"no-inner-declarations": "off",
Expand Down
674 changes: 361 additions & 313 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,10 @@
"eslint-plugin-n": "^16.0.2",
"eslint-plugin-promise": "^6.1.1",
"eslint-plugin-solid": "^0.13.0",
"laravel-vite-plugin": "^0.7.8",
"laravel-vite-plugin": "^0.8.1",
"postcss": "^8.4.29",
"prettier": "^3.0.3",
"sass": "^1.66.1",
"sass": "^1.69.0",
"tailwindcss": "^3.3.3",
"typescript": "^5.2.2",
"vite": "^4.4.9",
Expand Down
7 changes: 4 additions & 3 deletions resources/js/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {Outlet, Route, Routes, useParams} from "@solidjs/router";
import {Navigate, Outlet, Route, Routes, useParams} from "@solidjs/router";
import {createQuery} from "@tanstack/solid-query";
import {AccessBarrier} from "components/utils";
import {System} from "data-access/memo-api";
Expand All @@ -19,7 +19,7 @@ const App: VoidComponent = () => {
<Route path="/" component={RootPage}>
<UnknownNotFound />
<Route path="/help" component={NotYetImplemented} />
<Route path="/admin" component={AdminPages}>
<Route path="/admin" component={GlobalAdminPages}>
<UnknownNotFound />
<Route path="/facilities" component={AdminFacilitiesListPage} />
<Route path="/users" component={AdminUsersListPage} />
Expand All @@ -31,6 +31,7 @@ const App: VoidComponent = () => {
component={RootPageWithFacility}
>
<UnknownNotFound />
<Route path="/" element={<Navigate href="home" />} />
<Route path="/home" component={NotYetImplemented} />
<Route path="/meetings" component={NotYetImplemented} />
<Route path="/" component={FacilityStaffPages}>
Expand All @@ -54,7 +55,7 @@ export default App;

const UnknownNotFound: VoidComponent = () => <Route path="/*" component={NotFound} />;

const AdminPages: VoidComponent = () => (
const GlobalAdminPages: VoidComponent = () => (
<AccessBarrier roles={["globalAdmin"]}>
<Outlet />
</AccessBarrier>
Expand Down
12 changes: 6 additions & 6 deletions resources/js/components/ui/Table/TQueryTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,11 @@ export interface TQueryTableProps {
* A list of columns that are always included in the request (and available on the row objects),
* even if they are not visible.
*/
intrinsicColumns?: string[];
intrinsicColumns?: readonly string[];
/** Additional column names. Their options are taken from `columnOptions`. */
additionalColumns?: string[];
additionalColumns?: readonly string[];
/** Overrides for the definition of specific columns. */
columnOptions?: Partial<Record<string, ColumnOptions>>;
columnOptions?: Readonly<Partial<Record<string, ColumnOptions>>>;
/**
* The ordering of the columns. All the columns present on the backend and not present
* in this list are placed at the end.
Expand All @@ -94,12 +94,12 @@ export interface TQueryTableProps {
*
* In the current implementation the order of columns cannot be changed.
*/
initialColumnsOrder?: string[];
initialVisibleColumns?: string[];
initialColumnsOrder?: readonly string[];
initialVisibleColumns?: readonly string[];
initialSort?: SortingState;
initialPageSize?: number;
/** These columns are ignored and never shown. */
ignoreColumns?: string[];
ignoreColumns?: readonly string[];
/** Element to put below table, after the summary. */
customSectionBelowTable?: JSX.Element;
}
Expand Down
49 changes: 39 additions & 10 deletions resources/js/components/utils/AccessBarrier.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ export interface AccessBarrierProps extends Pick<QueryBarrierProps, "Error" | "P
facilityUrl?: string;
}

/** The roles for which querying facility permissions is necessary. */
const FACILITY_ROLES = new Set<PermissionKey>(["facilityMember", "facilityClient", "facilityStaff", "facilityAdmin"]);

/**
* Utility component that checks authentication
* state and user's permissions
Expand All @@ -52,18 +55,44 @@ export const AccessBarrier: ParentComponent<AccessBarrierProps> = (props) => {
props,
);
const [queryBarrierProps, localProps] = splitProps(merged, ["Error", "Pending"]);
const needFacilityPermissions = () => localProps.roles.some((role) => FACILITY_ROLES.has(role));
// Create the query even if it's not needed, it is fetched on all pages anyway.
const facilitiesQuery = createQuery(System.facilitiesQueryOptions);
const facilityId = () => facilitiesQuery.data?.find(({url}) => url === localProps.facilityUrl)?.id;

const statusQuery = createQuery(() => User.statusQueryOptions(facilityId()));
const accessGranted = () =>
statusQuery.isSuccess && localProps.roles?.every((role) => statusQuery.data?.permissions[role]);

const statusQuery = createQuery(() => {
// Only load the facility permissions if they are actually checked.
if (!needFacilityPermissions()) {
return User.statusQueryOptions();
}
if (!facilitiesQuery.isSuccess) {
// Return a pending query, waiting for the facilities to resolve or appear.
// If the status is error, the outer QueryBarrier will show the error.
return {enabled: false, queryKey: ["pending"]};
}
const facilityId = facilitiesQuery.data?.find(({url}) => url === localProps.facilityUrl)?.id;
if (facilityId) {
return User.statusWithFacilityPermissionsQueryOptions(facilityId);
}
return {
queryKey: ["error"],
queryFn: () => {
throw new Error(`Facility with URL ${localProps.facilityUrl} not found`);
},
};
});
const accessGranted = () => {
if (!statusQuery.isSuccess) {
return false;
}
const permissions = statusQuery.data!.permissions as Partial<Record<PermissionKey, boolean>>;
return localProps.roles?.every((role) => permissions[role]);
};
return (
<QueryBarrier queries={[statusQuery]} {...queryBarrierProps}>
<Show when={accessGranted()} fallback={<localProps.Fallback />}>
{merged.children}
</Show>
<QueryBarrier queries={needFacilityPermissions() ? [facilitiesQuery] : []} {...queryBarrierProps}>
<QueryBarrier queries={[statusQuery]} {...queryBarrierProps}>
<Show when={accessGranted()} fallback={<localProps.Fallback />}>
{merged.children}
</Show>
</QueryBarrier>
</QueryBarrier>
);
};
80 changes: 51 additions & 29 deletions resources/js/components/utils/InitializeTanstackQuery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,29 @@ import {
QueryCache,
QueryClient,
QueryClientProvider,
createQuery,
useQueryClient,
type MutationMeta,
type QueryMeta,
} from "@tanstack/solid-query";
import {isAxiosError} from "axios";
import {System, User} from "data-access/memo-api";
import {SolidQueryOpts} from "data-access/memo-api/query_utils";
import {Api} from "data-access/memo-api/types";
import {For, ParentComponent, Show, VoidComponent, createMemo} from "solid-js";
import {For, ParentComponent, Show, VoidComponent, createMemo, createSignal} from "solid-js";
import toast from "solid-toast";
import {cx, useLangFunc} from ".";
import {translationsLoaded, translationsLoadedPromise} from "../../i18n_loader";
import {MemoLoader} from "../ui";

/** A list of HTTP response status codes for which a toast should not be displayed. */
type QuietHTTPStatuses = number[];

declare module "@tanstack/query-core" {
interface QueryMeta {
quietError?: boolean;
quietHTTPStatuses?: QuietHTTPStatuses;
}
interface MutationMeta {
quietError?: boolean;
quietHTTPStatuses?: QuietHTTPStatuses;
isFormSubmit?: boolean;
}
}
Expand All @@ -34,27 +39,35 @@ export const InitializeTanstackQuery: ParentComponent = (props) => {
const t = useLangFunc();
function toastErrors(error: Error, meta?: Partial<QueryMeta & MutationMeta>) {
if (!isAxiosError<Api.ErrorResponse>(error)) return;
if ((error?.status && error.status >= 500) || !meta?.quietError) {
let errors = error.response?.data.errors;
if (meta?.isFormSubmit) {
// Validation errors will be handled by the form.
errors = errors?.filter((e) => !Api.isValidationError(e));
}
const status = error.response?.status;
if (!status || !meta?.quietHTTPStatuses?.includes(status)) {
const respErrors = error.response?.data.errors;
const errors = meta?.isFormSubmit
? // Validation errors will be handled by the form.
respErrors?.filter((e) => !Api.isValidationError(e))
: respErrors;
if (errors?.length) {
const errorMessages = errors.map((e) =>
t(e.code, {
...(Api.isValidationError(e) ? {attribute: e.field} : undefined),
...e.data,
}),
);
for (const msg of errorMessages) {
console.warn(`Error toast shown: ${msg}`);
if (!translationsLoaded()) {
for (const e of errors) {
console.warn("Error toast shown (translations not ready):", e);
}
}
toast.error(() => (
<ul class={cx({"list-disc pl-6": errorMessages.length > 1})} style={{"overflow-wrap": "anywhere"}}>
<For each={errorMessages}>{(msg) => <li>{msg}</li>}</For>
</ul>
));
translationsLoadedPromise.then(() => {
const messages = errors.map((e) => {
return t(e.code, {
...(Api.isValidationError(e) ? {attribute: e.field} : undefined),
...e.data,
});
});
for (const msg of messages) {
console.warn(`Error toast shown: ${msg}`);
}
toast.error(() => (
<ul class={cx({"list-disc pl-6": messages.length > 1})} style={{"overflow-wrap": "anywhere"}}>
<For each={messages}>{(msg) => <li>{msg}</li>}</For>
</ul>
));
});
}
}
}
Expand All @@ -64,10 +77,14 @@ export const InitializeTanstackQuery: ParentComponent = (props) => {
defaultOptions: {
queries: {
refetchOnReconnect: true,
refetchOnMount: false,
refetchOnWindowFocus: false,
// When opening a page, reload data if it's older than a couple of seconds.
staleTime: 5 * 1000,
retry: false,
retryOnMount: false,
// This is very important. The default reconcile algorithm somehow breaks the data and
qelo marked this conversation as resolved.
Show resolved Hide resolved
// reactivity in complicated ways. This line is basically `broken: false`.
// See https://github.com/TanStack/query/pull/6125
// About `reconcile`: https://github.com/TanStack/query/pull/5287
reconcile: false,
},
},
queryCache: new QueryCache({
Expand All @@ -90,11 +107,16 @@ export const InitializeTanstackQuery: ParentComponent = (props) => {
);
};

/** Initialize some of the required queries beforehand, but don't block on them. */
/** Prefetch some of the required queries beforehand. */
const InitQueries: VoidComponent = () => {
const queries = [createQuery(System.facilitiesQueryOptions), createQuery(User.statusQueryOptions)];
const queryClient = useQueryClient();
const fetchPromises = [System.facilitiesQueryOptions(), User.statusQueryOptions()].map((opts) =>
queryClient.fetchQuery(opts as SolidQueryOpts<unknown>),
);
const [isPrefetching, setIsPrefetching] = createSignal(true);
Promise.allSettled(fetchPromises).then(() => setIsPrefetching(false));
return (
<Show when={queries.some((q) => q.isLoading)}>
<Show when={isPrefetching()}>
<MemoLoader />
</Show>
);
Expand Down
36 changes: 25 additions & 11 deletions resources/js/components/utils/QueryBarrier.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
import {CreateQueryResult} from "@tanstack/solid-query";
import {Match, ParentProps, Switch, VoidComponent, mergeProps} from "solid-js";
import {Match, ParentProps, Switch, VoidComponent, mergeProps, on} from "solid-js";
import {BigSpinner} from "../ui";
import {createEffect} from "solid-js";

export interface QueryBarrierProps {
/** List of queries to handle. */
queries: CreateQueryResult<unknown, unknown>[];
/**
* Component to show, when query is in error state
* If set, the barrier waits until the first fetch is done, even if data was available in the cache.
* See `Query.isFetchedAfterMount`.
*/
ignoreCachedData?: boolean;
/** Component to show when query is in error state. */
Error?: VoidComponent;
/**
* Component to show, when query is in pending state
*/
/** Component to show when query is in pending state. */
Pending?: VoidComponent;
/**
* List of queries to handle
*/
queries: CreateQueryResult<unknown, unknown>[];
}

/**
Expand All @@ -31,9 +31,23 @@ export function QueryBarrier(props: ParentProps<QueryBarrierProps>) {
},
props,
);

createEffect(
// Don't rerun when fields on queries change, just the top level props.
on([() => props.queries, () => props.ignoreCachedData], ([queries, ignoreCachedData]) => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

po co on() ?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Właśnie linię wyżej jest wyjaśnienie.

if (ignoreCachedData) {
// We expect all queries to have fresh (not cached) data, so force fetch on those queries
// that didn't start their first fetch yet.
for (const query of queries) {
if (!query.isFetchedAfterMount && !query.isFetching) {
query.refetch();
}
}
}
}),
);
const isError = () => merged.queries.some(({isError}) => isError);
const isSuccess = () => merged.queries.every(({isSuccess}) => isSuccess);
const isSuccess = () =>
merged.queries.every((query) => query.isSuccess && (!props.ignoreCachedData || query.isFetchedAfterMount));
const isPending = () => !isError() && !isSuccess();

return (
Expand Down
4 changes: 2 additions & 2 deletions resources/js/data-access/memo-api/groups/Admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ export namespace Admin {

const getUsersListBase = (request?: Api.Request.GetListParams, config?: Api.Config) =>
V1.get<Api.Response.GetList<AdminUserResource>>("/admin/user/list", {...config, params: request});
export const getUsersList = (request?: Api.Request.GetListParams, config?: Api.Config) =>
const getUsersList = (request?: Api.Request.GetListParams, config?: Api.Config) =>
getUsersListBase(request, config).then(parseGetListResponse);
export const getUser = createGetFromList(getUsersListBase);
const getUser = createGetFromList(getUsersListBase);

export const createMember = (member: Api.Request.Create<MemberResource>, config?: Api.Config) =>
V1.post("/admin/member", member, config);
Expand Down
4 changes: 3 additions & 1 deletion resources/js/data-access/memo-api/groups/System.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {parseGetListResponse} from "../utils";
* @see {@link http://localhost:9081/api/documentation#/System local docs}
*/
export namespace System {
export const getFacilitiesList = (config?: Api.Config) =>
const getFacilitiesList = (config?: Api.Config) =>
V1.get<Api.Response.GetList<FacilityResource>>("/system/facility/list", config).then(parseGetListResponse);

export const keys = {
Expand All @@ -23,6 +23,8 @@ export namespace System {
({
queryFn: ({signal}) => getFacilitiesList({signal}),
queryKey: keys.facilityList(),
// Prevent refetching on every page.
staleTime: 10 * 60 * 1000,
}) satisfies SolidQueryOptions;

export function useInvalidator() {
Expand Down
Loading