Skip to content

Commit

Permalink
Add RJST (React JSON Schema Table) component
Browse files Browse the repository at this point in the history
Change-type: minor
  • Loading branch information
JSReds committed Jan 15, 2025
1 parent ff84c57 commit 729c1eb
Show file tree
Hide file tree
Showing 72 changed files with 9,162 additions and 115 deletions.
381 changes: 282 additions & 99 deletions package-lock.json

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,21 @@
"@rjsf/mui": "^5.24.1",
"@rjsf/utils": "^5.24.1",
"@rjsf/validator-ajv8": "^5.24.1",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"ajv-keywords": "^5.1.0",
"analytics-client": "^2.0.1",
"color": "^4.2.3",
"color-hash": "^2.0.2",
"date-fns": "^4.1.0",
"lodash": "^4.17.21",
"notistack": "^3.0.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-helmet": "^6.1.0",
"react-markdown": "^9.0.1",
"react-query": "^4.0.0-beta.23",
"react-router-dom": "^6.28.0",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.0",
Expand Down
39 changes: 39 additions & 0 deletions src/components/RJST/Actions/ActionContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import type { CheckedState } from '../components/Table/utils';
import type { RJSTAction } from '../schemaOps';
import { useQuery } from 'react-query';

export const LOADING_DISABLED_REASON = 'Loading';

interface ActionContentProps<T> {
action: RJSTAction<T>;
affectedEntries: T[] | undefined;
checkedState?: CheckedState;
getDisabledReason: RJSTAction<T>['isDisabled'];
onDisabledReady: (arg: string | null) => void;
}

// This component sole purpose is to have the useQuery being called exactly once per item,
// so that it satisfies React hooks assumption that the number of hook calls inside each component
// stays the same across renders.
export const ActionContent = <T extends object>({
action,
children,
affectedEntries,
checkedState,
getDisabledReason,
onDisabledReady,
}: React.PropsWithChildren<ActionContentProps<T>>) => {
useQuery({
queryKey: ['actionContent', action.title, affectedEntries, checkedState],
queryFn: async () => {
const disabled =
(await getDisabledReason?.({
affectedEntries,
checkedState,
})) ?? null;
onDisabledReady(disabled);
return disabled;
},
});
return children;
};
106 changes: 106 additions & 0 deletions src/components/RJST/Actions/Create.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import React from 'react';
import type {
ActionData,
RJSTContext,
RJSTModel,
RJSTBaseResource,
} from '../schemaOps';
import { rjstJsonSchemaPick } from '../schemaOps';
import { getCreateDisabledReason } from '../utils';
import { ActionContent, LOADING_DISABLED_REASON } from './ActionContent';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faMagic } from '@fortawesome/free-solid-svg-icons/faMagic';
import { Box, Button } from '@mui/material';
import { Spinner } from '../../Spinner';
import { useTranslation } from '../../../hooks/useTranslations';
import { Tooltip } from '../../Tooltip';

interface CreateProps<T extends RJSTBaseResource<T>> {
model: RJSTModel<T>;
rjstContext: RJSTContext<T>;
hasOngoingAction: boolean;
onActionTriggered: (data: ActionData<T>) => void;
}

export const Create = <T extends RJSTBaseResource<T>>({
model,
rjstContext,
hasOngoingAction,
onActionTriggered,
}: CreateProps<T>) => {
const { t } = useTranslation();
const { actions } = rjstContext;
const createActions = actions?.filter((a) => a.type === 'create');
const [disabledReasonsByAction, setDisabledReasonsByAction] =
React.useState<Record<string, string | undefined | null>>();
const [isInitialized, setIsInitialized] = React.useState(false);

React.useEffect(() => {
if (!isInitialized && createActions) {
setDisabledReasonsByAction(
Object.fromEntries(
createActions.map((a) => [a.title, LOADING_DISABLED_REASON]),
),
);
setIsInitialized(true);
}
}, [createActions, isInitialized]);

if (!createActions || createActions.length < 1 || !disabledReasonsByAction) {
return null;
}

if (createActions.length > 1) {
throw new Error('Only one create action per resource is allowed');
}

const [action] = createActions;

const disabledReason =
getCreateDisabledReason(model.permissions, hasOngoingAction, t) ??
disabledReasonsByAction[action.title];
return (
<Box display="flex">
<Tooltip
title={typeof disabledReason === 'string' ? disabledReason : undefined}
>
<Button
data-action={`create-${model.resource}`}
variant="contained"
onClick={() => {
onActionTriggered({
action,
schema: rjstJsonSchemaPick(
model.schema,
model.permissions.create,
),
});
}}
startIcon={<FontAwesomeIcon icon={faMagic} />}
disabled={!!disabledReason}
>
<ActionContent<T>
action={action}
getDisabledReason={action.isDisabled}
affectedEntries={undefined}
checkedState={undefined}
onDisabledReady={(result) => {
setDisabledReasonsByAction((disabledReasonsState) => ({
...disabledReasonsState,
[action.title]: result,
}));
}}
>
<Box display="flex" justifyContent="space-between">
{action.title}
<Spinner
sx={{ ml: 2 }}
show={disabledReason === LOADING_DISABLED_REASON}
/>
</Box>
</ActionContent>
</Button>
</Tooltip>
</Box>
);
};
139 changes: 139 additions & 0 deletions src/components/RJST/Actions/Tags.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import React from 'react';
import type { JSONSchema7 as JSONSchema } from 'json-schema';
import type { RJSTContext, RJSTBaseResource } from '../schemaOps';
import { parseDescriptionProperty } from '../schemaOps';
import get from 'lodash/get';
import { useTranslation } from '../../../hooks/useTranslations';
import type {
ResourceTagSubmitInfo,
SubmitInfo,
} from '../../TagManagementDialog/models';
import { closeSnackbar, enqueueSnackbar } from 'notistack';
import { useQuery } from 'react-query';
import { Spinner } from '../../Spinner';
import { TagManagementDialog } from '../../TagManagementDialog';

interface TagsProps<T> {
selected: T[] | undefined;
rjstContext: RJSTContext<T>;
schema: JSONSchema;
setIsBusyMessage: (message: string | undefined) => void;
onDone: () => void;
refresh?: () => void;
}

export const Tags = <T extends RJSTBaseResource<T>>({
selected,
rjstContext,
schema,
setIsBusyMessage,
refresh,
onDone,
}: TagsProps<T>) => {
const { t } = useTranslation();

const { sdk, internalPineFilter, checkedState } = rjstContext;

const getAllTags = sdk?.tags && 'getAll' in sdk.tags ? sdk.tags.getAll : null;

// This will get nested property names based on the x-ref-scheme property.
const getItemName = (item: T) => {
const property = schema.properties?.[
rjstContext.nameField as keyof typeof schema.properties
] as JSONSchema;
const refScheme = parseDescriptionProperty(property, 'x-ref-scheme');

if (refScheme != null && typeof refScheme === 'object') {
const field = refScheme[0];
const nameFieldItem = item[rjstContext.nameField as keyof T];
return get(
property.type === 'array'
? (nameFieldItem as Array<T[keyof T]>)?.[0]
: nameFieldItem,
field,
);
}

return item[rjstContext.nameField as keyof T];
};

const { data: items, isLoading } = useQuery({
queryKey: [
'tableTags',
internalPineFilter,
checkedState,
getAllTags,
selected == null,
],
queryFn: async () => {
if (
// we are in server side pagination
selected == null &&
checkedState === 'all' &&
getAllTags
) {
return (await getAllTags(internalPineFilter)) ?? null;
}
return selected ?? null;
},
});

const changeTags = React.useCallback(
async (tags: SubmitInfo<ResourceTagSubmitInfo, ResourceTagSubmitInfo>) => {
if (!sdk?.tags) {
return;
}

setIsBusyMessage(t(`loading.updating_tags`));
enqueueSnackbar({
key: 'change-tags-loading',
message: t(`loading.updating_tags`),
preventDuplicate: true,
});

try {
await sdk.tags.submit(tags);
enqueueSnackbar({
key: 'change-tags',
message: t('success.tags_updated_successfully'),
variant: 'success',
preventDuplicate: true,
});
refresh?.();
} catch (err: any) {
enqueueSnackbar({
key: 'change-tags',
message: err.message,
variant: 'error',
preventDuplicate: true,
});
} finally {
closeSnackbar('change-tags-loading');
setIsBusyMessage(undefined);
}
},
[sdk?.tags, refresh, setIsBusyMessage, t],
);

if (!rjstContext.tagField || !rjstContext.nameField || !items) {
return null;
}

return (
<Spinner show={isLoading} sx={{ width: '100%', height: '100%' }}>
<TagManagementDialog<T>
items={items}
itemType={rjstContext.resource}
titleField={getItemName ?? (rjstContext.nameField as keyof T)}
tagField={rjstContext.tagField as keyof T}
done={async (tagSubmitInfo) => {
await changeTags(tagSubmitInfo);
onDone();
}}
cancel={() => {
onDone();
}}
/>
</Spinner>
);
};
Loading

0 comments on commit 729c1eb

Please sign in to comment.